Project file structure:
=======================
./
    __init__.py
    __main__.py
    config.py
    cli/
        __init__.py
        commands.py
        main.py
        registry.py
        shell.py
        shell_parts/
            ai_logic.py
            handlers.py
            ui_utils.py
            __pycache__/
        __pycache__/
    local/
        file_manager.py
        ui.py
        utils.py
        contextifier/
            __init__.py
            engine.py
            __pycache__/
        data_engine/
            __pycache__/
        file_manager/
            __init__.py
            commands.py
            edit_ops.py
            git_ops.py
            io_ops.py
            path_ops.py
            __pycache__/
        healer/
            __init__.py
            runner.py
            __pycache__/
        memory/
            __init__.py
            index.py
            services/
                __init__.py
                embedding_cache.py
                git_lookup.py
                manual_notes.py
                note_generator.py
                semantic_search.py
                store.py
                __pycache__/
            utils/
                __init__.py
                summarizer.py
                __pycache__/
            __pycache__/
        __pycache__/
    nova_core/
        __init__.py
        ai/
            __init__.py
            api_client.py
            utils.py
            __pycache__/
        auth/
            __init__.py
            client.py
            storage.py
            __pycache__/
        performance/
            __pycache__/
        __pycache__/
    __pycache__/


File Contents:
===============


--- FILE: __init__.py ---

import importlib.metadata

try:
    # Dynamically fetch the version installed via pip
    __version__ = importlib.metadata.version("nova-bridgeye")
except importlib.metadata.PackageNotFoundError:
    # Fallback if running from source without installing
    __version__ = "0.1.5.2"

--- FILE: __main__.py ---

from nova_cli.cli.main import main

if __name__ == "__main__":
    main()


--- FILE: config.py ---

import os
from dotenv import load_dotenv

load_dotenv()


# -----------------------------
# NOVA CLI (API-only) Config
# -----------------------------
# This repo MUST NOT store model provider API keys.
# All AI + execution happens via NOVA_API over HTTP.
# Auth happens via nova-web.
# -----------------------------

# Location / display (optional; used only for UX)
LOCATION = os.getenv("NOVA_LOCATION", "India")
USER_NAME = os.getenv("NOVA_USER_NAME", "User")

# Defaults for CLI requests (sent to API)
DEFAULT_PROVIDER = os.getenv("NOVA_DEFAULT_PROVIDER", "openrouter")
DEFAULT_MODEL = os.getenv("NOVA_DEFAULT_MODEL", "openai/gpt-oss-120b")

# Base URLs
# - NOVA_API_BASE_URL points to the NOVA_API server (local or remote)
# - NOVA_AUTH_BASE_URL points to nova-web (Render)
NOVA_API_BASE_URL = os.getenv("NOVA_API_BASE_URL", "https://api.nova.bridgeye.com")
NOVA_AUTH_BASE_URL = os.getenv("NOVA_AUTH_BASE_URL", "https://nova.bridgeye.com")

# Client identity
NOVA_USER_AGENT = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")

# Debug toggles
DEBUG_AUTH = os.getenv("NOVA_DEBUG_AUTH", "").strip() in ("1", "true", "TRUE", "yes", "YES")

# Git behavior (CLI-only UX; file_manager uses these)
GIT_AUTO_COMMIT = os.getenv("NOVA_GIT_AUTO_COMMIT", "").strip() in ("1", "true", "TRUE", "yes", "YES")
GIT_AUTO_PUSH = os.getenv("NOVA_GIT_AUTO_PUSH", "").strip() in ("1", "true", "TRUE", "yes", "YES")

# Context load toggle (CLI-side feature; does not affect API)
AUTO_CONTEXT_LOAD = os.getenv("NOVA_AUTO_CONTEXT_LOAD", "").strip() in ("1", "true", "TRUE", "yes", "YES")

# Overdrive Mode (Bypass [y/n] confirmations)
OVERDRIVE = False

# Dynamic Root Boundary
INITIAL_ROOT = os.path.abspath(os.getcwd())
PROJECT_ROOT = INITIAL_ROOT


--- FILE: cli/__init__.py ---



--- FILE: cli/commands.py ---

# NOVA_CLI/nova_cli/cli/commands.py

def register_core_commands(registry, shell):
    
    registry.register("login", shell.cmd_login)
    registry.register("nova login", shell.cmd_login)
    # model / git
    registry.register(":model", shell.cmd_model)
    registry.register(":overdrive", shell.cmd_overdrive)
    registry.register(":exit overdrive", shell.cmd_overdrive)
    registry.register(":exitoverdrive", shell.cmd_overdrive)
    registry.register(":exit-overdrive", shell.cmd_overdrive)
    registry.register(":overdrive-exit", shell.cmd_overdrive)
    registry.register(":overdriveexit", shell.cmd_overdrive)
    registry.register(":overdrive exit", shell.cmd_overdrive)
    registry.register(":makeroot", shell.cmd_makeroot)
    registry.register(":exitroot", shell.cmd_exitroot)
    registry.register(":freeroot", shell.cmd_exitroot)
    registry.register(":exit root", shell.cmd_exitroot)
    registry.register(":free root", shell.cmd_exitroot)
    registry.register(":gitoptions", shell.cmd_gitoptions)

    # reset / unload
    registry.register(":reset", shell.cmd_reset)
    registry.register("reset", shell.cmd_reset)

    registry.register(":unload", shell.cmd_unload)
    registry.register("unload", shell.cmd_unload)

    # map / ls
    registry.register(":map", shell.cmd_map)
    registry.register("ls", shell.cmd_map)

    # explicit file ops
    registry.register(":create", shell.cmd_create)
    registry.register(":delete", shell.cmd_delete)
    registry.register(":rename", shell.cmd_rename)

    # wizard / apply / paste
    registry.register(":wizard", shell.cmd_wizard)
    registry.register(":apply", shell.cmd_apply)
    registry.register(":paste", shell.cmd_paste)

    # load / navigation
    registry.register(":continue", shell.cmd_continue)
    registry.register("continue", shell.cmd_continue)
    registry.register(":load", shell.cmd_load)
    registry.register("cd", shell.cmd_cd)
    registry.register("pwd", shell.cmd_pwd)

    # execution / cleanup
    registry.register("run", shell.cmd_run)
    registry.register("clean", shell.cmd_clean)

    # build command
    registry.register("Build It", shell.cmd_build_it)
    registry.register("build it", shell.cmd_build_it)
    registry.register("Build", shell.cmd_build_it)
    registry.register("build", shell.cmd_build_it)

    # exit / help
    registry.register("exit", shell.cmd_exit)
    registry.register("quit", shell.cmd_exit)

    registry.register("help", shell.cmd_help)
    registry.register(":help", shell.cmd_help)
    registry.register("doctor", shell.cmd_doctor)
    registry.register("logout", shell.cmd_logout)
    registry.register(":logout", shell.cmd_logout)
    registry.register(":remember", shell.cmd_remember)
    registry.register(":consolidate", shell.cmd_consolidate)


--- FILE: cli/main.py ---

import sys

from nova_cli.cli.shell import NovaShell
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli import config as cli_config
from nova_cli.nova_core.auth.storage import is_logged_in, has_refresh_token


def _doctor() -> int:
    api = BridgeyeAPIClient()

    print("NOVA CLI — Doctor")
    print(f"- NOVA_API_BASE_URL:  {cli_config.NOVA_API_BASE_URL}")
    print(f"- NOVA_AUTH_BASE_URL: {cli_config.NOVA_AUTH_BASE_URL}")
    print(f"- Default provider:   {cli_config.DEFAULT_PROVIDER}")
    print(f"- Default model:      {cli_config.DEFAULT_MODEL}")

    api_ok = api.health()
    print(f"- API reachable:      {'yes' if api_ok else 'no'}")

    logged_in = is_logged_in()
    print(f"- Logged in:          {'yes' if logged_in else 'no'}")

    # Helpful extra signal: refresh token presence
    rt = has_refresh_token()
    print(f"- Has refresh token:  {'yes' if rt else 'no'}")

    if not api_ok:
        print("\nFix:")
        print("  1) Start NOVA_API")
        print("  2) Or set NOVA_API_BASE_URL to the right host/port")
        return 1

    if not logged_in:
        print("\nFix:")
        print("  Run: nova login")
        return 1

    return 0


def main():
    try:
        shell = NovaShell()
    except Exception as e:
        from nova_cli.local.ui import ui
        ui.display_error(f"NOVA failed to initialize: {e}\n\nPlease report this to Bridgeye at support@bridgeye.com.")
        return

    # No args => interactive
    if len(sys.argv) == 1:
        shell.run()
        return

    cmd = sys.argv[1]
    args = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else ""

    if cmd in ("help", "--help", "-h"):
        shell.cmd_help("")
        return

    if cmd == "doctor":
        raise SystemExit(_doctor())

    if cmd == "run":
        shell.cmd_run(args)
        return

    if cmd == "clean":
        shell.cmd_clean(args)
        return

    if cmd == "login":
        shell.cmd_login(args)
        return

    # Optional legacy alias
    if cmd == "heal":
        shell.cmd_run(args)
        return

    # Anything else => treat as chat prompt (one-shot)
    prompt = " ".join(sys.argv[1:])
    shell.handle_ai_request(prompt)


if __name__ == "__main__":
    main()


--- FILE: cli/registry.py ---

# NOVA_CLI\nova_cli\cli\registry.py

class CommandRegistry:
    def __init__(self):
        self._commands = {}

    def register(self, name, handler):
        self._commands[name] = handler

    def get(self, name):
        return self._commands.get(name)

    def all(self):
        return self._commands


--- FILE: cli/shell.py ---


import logging
import os
import shlex
import signal
import sys
import time

import core.state as state
import core.prompts as prompts
from nova_cli import config
from nova_cli.cli.commands import register_core_commands
from nova_cli.cli.registry import CommandRegistry

from nova_cli.cli.shell_parts.ai_logic import ShellAILogicMixin
from nova_cli.cli.shell_parts.handlers import ShellHandlersMixin
from nova_cli.cli.shell_parts.ui_utils import ShellUIUtilsMixin

from nova_cli.local.ui import ui
from nova_cli.local.healer.runner import run_with_healing


class NovaShell(ShellHandlersMixin, ShellUIUtilsMixin, ShellAILogicMixin):
    def __init__(self):
        # --- CONFIGURE LOGGING (File only, no Console) ---
        logging.basicConfig(
            filename='nova.log',
            level=logging.INFO,
            format='%(asctime)s - %(levelname)s - %(message)s'
        )
        # Prevent logs from propagating to the console (stderr)
        logging.getLogger().propagate = False
        for handler in logging.root.handlers[:]:
            if not isinstance(handler, logging.FileHandler):
                logging.root.removeHandler(handler)

        self.state = state.SessionState()
        self.interface = ui

        # API-only mode (no local model client)
        self.groq_client = None
        self.chat_session = None
        self.context_loader = None

        # Configuration
        self.provider = config.DEFAULT_PROVIDER
        self.model_name = config.DEFAULT_MODEL

        # Command Registry
        self.registry = CommandRegistry()
        register_core_commands(self.registry, self)
        
        # --- PROMPT TOOLKIT SETUP (Replaces Readline for perfect scrolling/wrapping) ---
        self.history_file = os.path.expanduser("~/.nova_history")
        
        from prompt_toolkit import PromptSession
        from prompt_toolkit.history import FileHistory
        from prompt_toolkit.completion import Completer, Completion
        
        class NovaCompleter(Completer):
            def __init__(self, registry):
                self.registry = registry

            def get_completions(self, document, complete_event):
                text = document.text_before_cursor
                if " " not in text:
                    for cmd in self.registry.all().keys():
                        if cmd.startswith(text):
                            yield Completion(cmd, start_position=-len(text))
                else:
                    import glob
                    word = text.split(" ")[-1]
                    matches = glob.glob(word + "*")
                    for m in matches:
                        display = m + ("/" if os.path.isdir(m) else "")
                        yield Completion(display, start_position=-len(word))
                        
        self.prompt_session = PromptSession(
            history=FileHistory(self.history_file),
            completer=NovaCompleter(self.registry)
        )
        
        # Load any interrupted build state from disk
        self.state.load_build_state()

        # Best-effort consolidation pass (mirrors Claude Code's Auto Dream), then load memory context
        from nova_cli.local.memory import maybe_consolidate_memory, get_memory_context_block
        try:
            maybe_consolidate_memory(os.getcwd())
        except Exception:
            pass
        self.project_memory_block = get_memory_context_block(os.getcwd())

    def get_prompt_text(self):
        prefix = ""
        if self.state.active_file:
            prefix = f"[dim]({os.path.basename(self.state.active_file)})[/dim] "
        if len(self.state.loaded_files) > 0:
            prefix += f"[dim][{len(self.state.loaded_files)} loaded][/dim] "
        
        # Color changes to RED in overdrive mode
        prompt_color = "bold red" if config.OVERDRIVE else "bold cyan"
        overdrive_indicator = "[bold red]O[/bold red] " if config.OVERDRIVE else ""
        
        return f"{prefix}{overdrive_indicator}[{prompt_color}]spark terminal >[/{prompt_color}]  "


    # --- COMMAND HANDLERS ---

    def run(self):
        title = "NOVA"
        
        # 1. Kernel level rename (Changes name in Activity Monitor/Task Manager)
        import ctypes
        try:
            if sys.platform == "darwin":
                libc = ctypes.CDLL(None)
                libc.setprogname(title.encode('utf-8'))
            elif sys.platform == "win32":
                ctypes.windll.kernel32.SetConsoleTitleW(title)
        except Exception: pass

        # 2. The Title Sequence (Triggers VS Code ${sequence} setting)
        def force_tab_rename():
            # \x1b]0; -> Standard code to set tab/window title
            # We write to __stdout__ to bypass any filtering by UI libraries
            sys.__stdout__.write(f"\x1b]0;{title}\x07")
            sys.__stdout__.flush()

        self.interface.clear()
        force_tab_rename()
        
        logging.info(f"Session started in {config.PROJECT_ROOT}")

        # Simple non-interactive entry
        if len(sys.argv) > 1:
            cmd = sys.argv[1]
            args = sys.argv[2:] if len(sys.argv) > 2 else []

            if cmd == "run":
                if args:
                    try:
                        output = run_with_healing(
                            command_args=args,
                            cwd=os.getcwd(),
                            model=self.model_name,
                            provider=self.provider,
                            context=self.state.loaded_files,
                            repo_map=prompts.get_repo_map_cached(os.getcwd()),
                        )
                        if output and output != "SUCCESS_SIGNAL":
                            print(output)
                    except Exception as e:
                        print(f"Healer Error: {e}")
                    return
                print("Usage: nova run <command>")
                return

            if cmd == "clean":
                # in non-interactive mode, clean requires loaded context; keep it simple for now
                print("Use interactive mode for clean (load files first).")
                return

            if cmd.lower() == "build":
                self.cmd_build_it(args)
                return

        self.interface.display_header(self.model_name, os.getcwd())
        from nova_cli.nova_core.auth.storage import is_logged_in
        self.interface.display_startup_hint(is_logged_in())

        from prompt_toolkit.formatted_text import ANSI
        
        while True:
            try:
                # Re-assert name to handle Shell Integration resets
                force_tab_rename()

                # Capture Rich formatted prompt to pass into PromptSession
                with self.interface.console.capture() as cap:
                    self.interface.console.print(self.get_prompt_text(), end="")

                cmd_raw = self.prompt_session.prompt(ANSI(cap.get())).strip()
                if not cmd_raw:
                    continue

                try:
                    parts = shlex.split(cmd_raw)
                except ValueError:
                    # Fallback for natural language containing unbalanced quotes (e.g. "How're you?")
                    parts = cmd_raw.split()

                ai_prompt = None
                handler = None
                cmd_base = ""
                args = ""

                # 1. Search for the longest matching registered command (handles multi-word commands)
                sorted_commands = sorted(self.registry.all().keys(), key=len, reverse=True)
                for cmd_name in sorted_commands:
                    if cmd_raw.lower().startswith(cmd_name.lower()):
                        # Check if it's a full word match to avoid ":exit" matching ":exitroot"
                        if len(cmd_raw) == len(cmd_name) or cmd_raw[len(cmd_name)] == " ":
                            handler = self.registry.get(cmd_name)
                            cmd_base = cmd_name
                            args = cmd_raw[len(cmd_name):].strip()
                            break

                if not handler:
                    cmd_base = parts[0]
                    args = " ".join(shlex.quote(p) for p in parts[1:]) if len(parts) > 1 else ""

                if handler:
                    # Pass the joined but safely quoted string to the handler
                    result = handler(args)
                    if result == "EXIT":
                        break
                    if isinstance(result, str):
                        ai_prompt = result
                else:
                    ai_prompt = cmd_raw

                if ai_prompt:
                    self.handle_ai_request(ai_prompt)

            except KeyboardInterrupt:
                # Handle Ctrl+C: Clear the current line and return to prompt
                self.interface.print("\n[yellow]>> Operation cancelled.[/yellow]")
                continue
            except EOFError:
                with self.interface.console.status("[bold red]SHUTDOWN: Closing Nova...", spinner="line", spinner_style="red"):
                    time.sleep(0.5)
                break


--- FILE: cli/shell_parts/ai_logic.py ---

import json
import logging
import os
import re
import sys
import time

from rich.panel import Panel

from core import prompts
from nova_cli import config
from nova_cli.cli.shell_parts.ui_utils import extract_enhanced_prompt
from nova_cli.local.contextifier.engine import run_contextify
from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.healer.runner import run_with_healing
from nova_cli.local.utils import extract_code_from_markdown, ensure_dependencies
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient


def _read_text(path: str) -> str:
    """Helper to read file content for validation."""
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        return f.read()


class ShellAILogicMixin:

    def handle_ai_request(self, prompt_text, override_model=None, nlp_intent=None):
        """
        Orchestrates AI interaction with automated context discovery.
        """
        lower_p = prompt_text.lower()
        _nova_memory_raw_prompt = prompt_text

        # --- MEMORY GATE: Deterministic "latest change" query intercept ---
        latest_change_keywords = [
            "latest change", "last change", "what did we do last",
            "what was the last change", "recent change made",
            "what have we done so far", "what did you do last time",
            "last thing we did", "last update to this project",
            "what was the last thing you did",
        ]
        if any(kw in lower_p for kw in latest_change_keywords) and "system_override" not in lower_p:
            from nova_cli.local.memory import get_latest_change
            _result = get_latest_change(os.getcwd())
            if _result.get("source") == "memory":
                self.interface.print(
                    f"[cyan]>> Latest recorded change ({_result['timestamp']}):[/cyan]\n"
                    f"Intent: {_result['intent']}\n"
                    f"Request: {_result['request_summary']}\n"
                    f"Outcome: {_result['outcome']}"
                )
            elif _result.get("source") == "git":
                self.interface.print(
                    f"[cyan]>> No Nova memory found for this project. Latest git commit:[/cyan]\n"
                    f"[{_result['hash']}] {_result['message']} — {_result['author']} ({_result['timestamp']})"
                )
            else:
                self.interface.print("[yellow]>> No memory or git history found for this project.[/yellow]")
            return

        # --- MEMORY GATE: Cross-project semantic search intercept ---
        cross_project_keywords = [
            "across projects", "across all projects", "all my projects",
            "which project did i", "which project had", "in which project",
            "any of my projects", "across my projects", "what project did i",
            "search all projects", "search across projects",
        ]
        if any(kw in lower_p for kw in cross_project_keywords) and "system_override" not in lower_p:
            from nova_cli.local.memory import search_across_projects
            _cross_result = search_across_projects(prompt_text, top_k=5)
            self.interface.print(f"[cyan]>> {_cross_result}[/cyan]")
            return

        # --- 0. SILENT LAB SENSING (Step 1 of Revamp) ---
        if self.state.load_lab_state():
            logging.info("Lab environment detected. Injecting lab state into memory.")

            # RECOVERY CHECK: If user says "start where you left" and a lab is active
            if any(
                k in lower_p
                for k in ["start where", "start from where", "continue build"]
            ):
                self.interface.print(
                    "[cyan]>> Resuming Lab Research from memory...[/cyan]"
                )
                return self._execute_skill_flow(
                    "data-science", prompt_text, override_model
                )

            if "experiments" not in lower_p and not nlp_intent:
                logging.info("Omnipresent Lab Sensing triggered.")

        # --- JIT CONTEXT RETRIEVAL & REACTIVE ROUTING (Step 3 & 6 of Revamp) ---
        if self.state.lab_active:
            # 1. JIT Loading
            exp_match = re.search(r"(?:experiment|exp)\s*(\d+)", lower_p)
            if exp_match:
                exp_num = exp_match.group(1)
                # Try to find the file in the experiments folder
                potential_files = [
                    f"experiments/experiment_{exp_num}.py",
                    f"experiments/experiment_{exp_num}.R",
                ]
                for pf in potential_files:
                    if os.path.exists(pf):
                        self._step_start(
                            f"JIT Memory: Loading {pf} for specific context..."
                        )
                        abs_p = os.path.abspath(pf).replace("\\", "/")
                        base = os.path.basename(abs_p)
                        with open(abs_p, "r", encoding="utf-8") as f:
                            self.state.loaded_files[base] = f.read()
                        self.state.loaded_paths[base] = abs_p
                        self._step_ok(f"Memory retrieved.")
                        break

            # 2. Reactive Artifact Interception
            # If user asks for report changes or plots/tables and we are in a lab
            artifact_keywords = [
                "report",
                "plot",
                "graph",
                "table",
                "chart",
                "viz",
                "metric",
                "calculate",
            ]
            if any(kw in lower_p for kw in artifact_keywords):
                logging.info("Reactive Sidecar Logic triggered.")
                intent = "SKILLS"
                nlp_intent = "SKILLS"  # Force route to data-science skill

        # DNA Identification (Dynamic Scan)
        dna = {"type": "empty", "root_path": os.getcwd(), "is_vite": False}
        try:
            import importlib.util

            # Locate the scanner in the sibling skills directory
            _base_path = os.path.dirname(
                os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
            )
            _scanner_script = os.path.join(
                _base_path, "skills", "frontend-web", "dna_scanner.py"
            )
            if os.path.exists(_scanner_script):
                _spec = importlib.util.spec_from_file_location(
                    "dna_scanner_logic", _scanner_script
                )
                _mod = importlib.util.module_from_spec(_spec)
                _spec.loader.exec_module(_mod)
                dna = _mod.scan_project_dna(os.getcwd())
        except Exception:
            pass

        try:
            plan_exists = os.path.exists(os.path.join(os.getcwd(), "PLAN.md"))
            api = BridgeyeAPIClient()

            # REPO OVERVIEW HEURISTIC (Needed for context discovery logic)
            repo_overview_keywords = [
                "about the repo", "about the folder", "about this project",
                "overview of the folder", "overview of the repo", "explain this project",
                "explain the repo", "explain this folder", "repo overview",
                "project overview", "folder overview",
            ]
            is_repo_overview = any(kw in lower_p for kw in repo_overview_keywords) and "system_override" not in lower_p

            # --- 1. SEMANTIC INTENT CLASSIFICATION & CLARIFICATION LOOP ---
            current_prompt = prompt_text
            classification = {}
            intent = None
            skill_match = None

            if "SYSTEM_OVERRIDE" in prompt_text:
                intent = nlp_intent or "PLAN"
            else:
                max_rounds = 2
                for _ in range(max_rounds):
                    # Call the ML Classifier Endpoint
                    classification = api.classify_intent(current_prompt)
                    
                    needs_clarification = (
                        classification.get("low_confidence") or 
                        classification.get("ambiguous") or 
                        classification.get("skill_low_confidence") or 
                        classification.get("skill_ambiguous")
                    )
                    
                    if not needs_clarification:
                        break
                        
                    # Generate plain English clarification question
                    bucket = classification.get("bucket")
                    if bucket == "SKILLS" and (classification.get("skill_low_confidence") or classification.get("skill_ambiguous")):
                        question = "This sounds like a special task. Did you want to build a website/app, or analyze some data?"
                    elif classification.get("ambiguous"):
                        question = "Just to make sure I do the right thing — could you clarify what you'd like me to do? (e.g. edit a file, build an app, delete something)"
                    else:
                        question = "I'm not completely sure what you'd like me to do. Could you clarify your request?"
                        
                    self.interface.print(f"\n[bold yellow]🤔 {question}[/bold yellow]")
                    answer = self.interface.input("[cyan]Clarify > [/cyan]").strip()
                    
                    if not answer or answer.lower() in ["q", "quit", "exit", "cancel", "nvm"]:
                        self.interface.print("[dim]Action cancelled.[/dim]")
                        return
                        
                    current_prompt = f"{current_prompt}. {answer}"
                
                # Sync the prompt back in case it was clarified
                prompt_text = current_prompt
                lower_p = prompt_text.lower()
                
                intent = classification.get("bucket", "GENERAL")
                skill_match = classification.get("skill")

            # --- 2. REPO OVERVIEW OVERRIDE ---
            if is_repo_overview:
                intent = "ANALYZE"

            # --- 3. ARCHITECT LOCK ---
            if not plan_exists and intent not in ["DELETE", "SKILLS", "GENERAL", "ANALYZE", "EDIT"]:
                intent = "PLAN"
                
            intent = intent or "GENERAL"
            is_web_query = (skill_match == "frontend-web")

            # --- 3.5. PROMPT ENHANCER GATE ---
            if "SYSTEM_OVERRIDE" not in prompt_text and self.should_use_prompt_enhancer(prompt_text, intent):
                enhanced = self.run_prompt_enhancement_flow(prompt_text)
                if not enhanced:
                    return # User cancelled
                
                # Set override to proceed to Architect Phase (PLAN.md creation)
                prompt_text = f"SYSTEM_OVERRIDE: Based on this approved plan, create the PLAN.md:\n{enhanced}"
                intent = "PLAN"

            # --- 4. SKILLS GATE (Hard Intercept) ---
            if intent == "SKILLS":
                if skill_match and skill_match != "NONE":
                    # [FIX]: If Vite project, bypass the single-file skill and force Architect mode for frontend
                    if skill_match == "frontend-web" and dna.get("is_vite"):
                        intent = "PLAN"
                    else:
                        self._execute_skill_flow(skill_match, prompt_text, override_model)
                        return
                else:
                    self.interface.print("[yellow]>> No matching skill found. Falling back to GENERAL intent.[/yellow]")
                    intent = "GENERAL"

            # --- 4. CONTEXT DISCOVERY (Automated Context Loading) ---
            if intent in ["EDIT", "CREATE", "PLAN", "ANALYZE"]:
                if is_repo_overview:
                    self._step_start(
                        "Scanning project architecture for comprehensive overview..."
                    )
                    self.state.loaded_files.clear()
                    self.state.loaded_paths.clear()
                    project_context = run_contextify(
                        config.PROJECT_ROOT, save_to_disk=True
                    )
                    self.state.loaded_files.update(project_context)
                    for rel_path in project_context.keys():
                        abs_p = os.path.abspath(rel_path).replace("\\", "/")
                        self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                    self._step_ok("Project context synchronized.")
                else:
                    mentioned_files = re.findall(r"[\w\-\/]+\.\w+", prompt_text)
                    for mf in mentioned_files:
                        # Ignore data files for text context; they are handled by skills
                        if any(
                            mf.endswith(ext)
                            for ext in [".csv", ".xlsx", ".parquet", ".json"]
                        ):
                            continue
                        if os.path.exists(mf):
                            self.state.active_file = os.path.abspath(mf).replace(
                                "\\", "/"
                            )
                            base = os.path.basename(self.state.active_file)
                            try:
                                with open(
                                    self.state.active_file, "r", encoding="utf-8"
                                ) as f:
                                    self.state.loaded_files[base] = f.read()
                                self.state.loaded_paths[base] = self.state.active_file
                            except Exception:
                                pass
                            # Load only the first relevant file mentioned as the "Active" file
                            break

            # --- 2. FILE DISCOVERY (Only after intent is known) ---
            if intent in ["EDIT", "CREATE", "ANALYZE", "SKILLS"]:
                mentioned_files = re.findall(r"[\w\-\/]+\.\w+", prompt_text)
                if mentioned_files:
                    for mf in mentioned_files:
                        if os.path.exists(mf):
                            self.state.active_file = os.path.abspath(mf).replace(
                                "\\", "/"
                            )
                            base = os.path.basename(self.state.active_file)
                            try:
                                with open(
                                    self.state.active_file, "r", encoding="utf-8"
                                ) as f:
                                    self.state.loaded_files[base] = f.read()
                                self.state.loaded_paths[base] = self.state.active_file
                            except Exception:
                                pass
                            break

            # --- BUILD REDIRECT ---
            if intent == "BUILD":
                self.interface.print(
                    "[bold green]>> Build intent detected. Triggering implementation sequence...[/bold green]"
                )
                self.cmd_build_it("")
                return

            # --- LOCAL DELETE INTERCEPT (Bypass AI API entirely) ---
            if intent == "DELETE":
                targets = []
                for word in prompt_text.split():
                    clean_word = word.strip("',.\"")
                    # STRICT MATCH: Only target files that actually exist on disk.
                    # This prevents accidental deletion attempts on version numbers (18.3.1), CSS classes (px-1.5), or JS snippets (window.X)
                    if os.path.exists(clean_word) and os.path.isfile(clean_word):
                        if clean_word not in targets:
                            targets.append(clean_word)

                if targets:
                    self.interface.print(
                        f"[cyan]>> Direct Action: Executing local delete for {', '.join(targets)}[/cyan]"
                    )
                    fake_output = "\n".join([f"[DELETE: {t}]" for t in targets])

                    modified_files = handle_ai_commands(fake_output)

                    if isinstance(modified_files, list) and modified_files:
                        for fpath in modified_files:
                            if fpath.startswith("DELETED:"):
                                del_path = fpath.split("DELETED:", 1)[1].strip()
                                abs_del = os.path.abspath(del_path).replace("\\", "/")
                                base_del = os.path.basename(abs_del)

                                if base_del in self.state.loaded_files:
                                    del self.state.loaded_files[base_del]
                                if base_del in self.state.loaded_paths:
                                    del self.state.loaded_paths[base_del]
                                if self.state.active_file == abs_del:
                                    self.state.active_file = None

                                self.interface.print(
                                    f"[dim]>> Removed {base_del} from AI context.[/dim]"
                                )

                        # Trigger full context regeneration after successful local deletions
                        if any(f.startswith("DELETED:") for f in modified_files):
                            self._step_start(
                                "Rebuilding project context after deletion..."
                            )
                            self.state.loaded_files.clear()
                            self.state.loaded_paths.clear()

                            project_context = run_contextify(
                                os.getcwd(), save_to_disk=True
                            )
                            self.state.loaded_files.update(project_context)
                            for rel_path in project_context.keys():
                                abs_p = os.path.abspath(rel_path).replace("\\", "/")
                                self.state.loaded_paths[os.path.basename(rel_path)] = (
                                    abs_p
                                )

                            self._step_ok(
                                "Context updated. project_context.txt regenerated."
                            )
                    return
                else:
                    self.interface.print(
                        "[yellow]>> Delete intent recognized, but no valid target file could be identified in the prompt.[/yellow]"
                    )
                    return
            # --- 2. MODEL & PROMPT CONFIGURATION ---
            target_model = override_model if override_model else self.model_name
            target_provider = self.provider

            # Implementation Gate: Kimi is the BUILDER. Groq 120B is the ARCHITECT.
            # PLAN intent MUST use the Groq 120B model to create PLAN.md.
            # EDIT and BUILD intents use KIMI K2.6.
            if (
                intent in ["EDIT", "BUILD"] or "PHASE: IMPLEMENTATION" in prompt_text
            ) and intent != "PLAN":
                target_model = "moonshotai/kimi-k2.6"
                target_provider = "openrouter"

                # [STEP 4]: Root vs Src Awareness
                # Use the DNA root path if identified, otherwise current directory
                scan_path = dna.get("root_path", os.getcwd())

                self._step_start(f"Synchronizing project context for {intent}...")

                # Clean up old context files in the root if they exist
                old_ctx = os.path.join(scan_path, "project_context.txt")
                if os.path.exists(old_ctx):
                    try:
                        os.remove(old_ctx)
                    except:
                        pass

                self.state.loaded_files.clear()
                self.state.loaded_paths.clear()

                def log_file(p):
                    if len(self.state.loaded_files) % 5 == 0:
                        self.interface.print(
                            f"[dim]  Scanning: {p}[/dim]", soft_wrap=True
                        )

                # Generate context from the Project Root, not just src
                project_context = run_contextify(
                    scan_path, verbose_callback=log_file, save_to_disk=True
                )
                self.state.loaded_files.update(project_context)
                for rel_path in project_context.keys():
                    abs_p = os.path.abspath(rel_path).replace("\\", "/")
                    self.state.loaded_paths[os.path.basename(rel_path)] = abs_p

                # Force-sync the active file to ensure the AI's immediate target is fresh.
                if self.state.active_file and os.path.exists(self.state.active_file):
                    base = os.path.basename(self.state.active_file)
                    try:
                        with open(self.state.active_file, "r", encoding="utf-8") as f:
                            self.state.loaded_files[base] = f.read()
                        self.state.loaded_paths[base] = self.state.active_file
                    except Exception:
                        pass

                self._step_ok(
                    f"Context verified. NOVA is now ready to identify and fix the code..."
                )
                self.interface.display_coding_mode(target_model)
            else:
                # ANALYSIS / Default
                target_model = override_model or self.model_name

            # Refresh and Fetch Map for Peripheral Vision from the identified scan_path
            prompts.clear_file_tree_cache()
            repo_map = prompts.get_repo_map_cached(
                scan_path if "scan_path" in locals() else os.getcwd()
            )

            # Inject Map into dynamic system instructions based on NLP intent
            if not override_model and ("SYSTEM_OVERRIDE" not in prompt_text or intent == "PLAN"):
                if intent == "EDIT":
                    # [FLOWCHART STEP 5]: Kimi K2.6 will take full context and give SEARCH REPLACE blocks.
                    phase_instruction = "YOU ARE IN SURGICAL EDIT MODE. PHASE: IMMEDIATE IMPLEMENTATION."
                    task_instruction = (
                        "1. Identify the relevant code bits across the project context.\n"
                        "2. Provide surgical [EDIT] tags immediately.\n"
                        "3. YOU MUST use the SEARCH/REPLACE block syntax for all updates.\n"
                        "4. Ensure your SEARCH block matches the project context exactly.\n"
                        "5. PREMIUM COMPONENT RULE (CRITICAL): If the user asks to change text, colors, or images inside a premium registry component (e.g., Hero, Footer, Background), you MUST edit the parent page (e.g., App.tsx, Storefront.tsx) where the component is imported and modify its props. DO NOT edit the raw component file itself!"
                    )
                elif intent == "PLAN":
                    phase_instruction = "YOU ARE IN THE ARCHITECT & PLANNING PHASE."

                    # UI-UX-PRO-MAX + SKILL.md INTEGRATION — single source of truth.
                    # Delegates to skills/frontend-web/builder.py's build_architect_design_context()
                    # instead of maintaining a separate duplicate implementation here. This also
                    # ensures SKILL.md (scope expansion rule, business-type sections, motion system,
                    # anti-patterns) reaches this live PLAN path, which it never did before.
                    ui_ux_pro_max_context = ""
                    try:
                        import importlib.util as _ilu

                        _query_text = prompt_text
                        if "USER_GOAL:" in prompt_text:
                            _query_text = prompt_text.split("USER_GOAL:")[-1].strip()

                        _builder_path = None
                        _spec_skills = _ilu.find_spec("skills")
                        if _spec_skills and _spec_skills.submodule_search_locations:
                            _builder_path = os.path.join(_spec_skills.submodule_search_locations[0], "frontend-web", "builder.py")

                        if not _builder_path or not os.path.exists(_builder_path):
                            _root_4 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                            _builder_path = os.path.join(_root_4, "skills", "frontend-web", "builder.py")
                            if not os.path.exists(_builder_path):
                                _root_3 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
                                _builder_path = os.path.join(_root_3, "skills", "frontend-web", "builder.py")

                        if _builder_path and os.path.exists(_builder_path):
                            _b_spec = _ilu.spec_from_file_location("web_builder_shared", _builder_path)
                            _web_builder_mod = _ilu.module_from_spec(_b_spec)
                            _b_spec.loader.exec_module(_web_builder_mod)
                            _design_context = _web_builder_mod.build_architect_design_context(_query_text)
                            if _design_context:
                                ui_ux_pro_max_context = f"\n\n15. {_design_context}\n"
                        else:
                            self.interface.print("[red]>> Notice: builder.py not found for design intelligence lookup[/red]")
                    except Exception as e:
                        self.interface.print(f"[red]>> Notice: Design intelligence lookup error - {e}[/red]")

                    # REGISTRY INJECTION FOR VITE PIPELINE
                    registry_context = ""
                    try:
                        import json
                        import importlib.util as _reg_ilu
                        
                        _plan_reg_base = None
                        _p_spec = _reg_ilu.find_spec("skills")
                        if _p_spec and _p_spec.submodule_search_locations:
                            _plan_reg_base = os.path.join(_p_spec.submodule_search_locations[0], "frontend-web", "registry")
                        
                        if not _plan_reg_base or not os.path.exists(_plan_reg_base):
                            _rt4 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                            _plan_reg_base = os.path.join(_rt4, "skills", "frontend-web", "registry")
                            if not os.path.exists(_plan_reg_base):
                                _rt3 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
                                _plan_reg_base = os.path.join(_rt3, "skills", "frontend-web", "registry")

                        if _plan_reg_base and os.path.exists(_plan_reg_base):
                            _reg_index_path = os.path.join(_plan_reg_base, "registry_index.json")
                            if os.path.exists(_reg_index_path):
                                with open(_reg_index_path, "r", encoding="utf-8") as _rf:
                                    _raw_data = json.load(_rf)
                                    _clean_data = {}
                                    for _cat, _items in _raw_data.items():
                                        if isinstance(_items, list):
                                            _clean_data[_cat] = [
                                                {"name": i.get("name"), "path": i.get("path"), "best_for": i.get("best_for")}
                                                for i in _items if isinstance(i, dict)
                                            ]
                                    registry_context = (
                                        "\n\n=========================================\n"
                                        "CRITICAL MANDATE: PREMIUM COMPONENT REGISTRY\n"
                                        "=========================================\n"
                                        "You are REQUIRED to use components from this exact registry list to build the website UI:\n"
                                        f"{json.dumps(_clean_data, indent=2)}\n\n"
                                        "MANDATORY RULES FOR REGISTRY USAGE:\n"
                                        "1. EXHAUSTIVE SELECTION: Analyze the website sections you are planning. You MUST actively pick a premium component for EVERY applicable section (e.g., Navigation/Sidebars, Hero, Backgrounds, Cards, Modals, Footers).\n"
                                        "2. STRICT TAG MATCHING (CRITICAL): You are FORBIDDEN from selecting a component unless its 'best_for' array explicitly matches the business domain (e.g. 'finance', 'dashboard', 'corporate saas') or your chosen aesthetic. NEVER use 'cyberpunk' or 'gaming' components for professional/finance apps!\n"
                                        "3. EXACT NAMING & ZERO HALLUCINATIONS (CRITICAL): You MUST use the EXACT filename provided in the JSON above (e.g., use 'ExpandableSidebar.tsx', DO NOT output a generic 'Sidebar.tsx'). DO NOT output 'Navbar.tsx' if 'FloatingHeader.tsx' is the registry component you chose. You are strictly forbidden from inventing generic component names for structural areas covered by the registry.\n"
                                        "4. EXPLICIT FILE STRUCTURE (CRITICAL): In your PLAN.md 'File Structure' section, you MUST explicitly write out EVERY SINGLE FILE PATH for your chosen components on its own line (e.g., `src/components/ui/ExpandableSidebar.tsx`). Do NOT group them like 'ui/ (premium components)'. List each one individually! If you omit the .tsx paths, they will not be built!\n"
                                        "5. DEDICATED SECTION: You MUST create a section named exactly '## Premium Components' in PLAN.md and list your exact choices.\n"
                                        "6. ANTI-HALLUCINATION (CRITICAL): The user request might ask for concepts (like 'a sidebar', 'a dashboard card'). You are STRICTLY FORBIDDEN from making up your own component files for these. Map them to the real registry files.\n"
                                        "=========================================\n"
                                    )
                                    # Debug print so we know the menu was found!
                                    self.interface.print("[dim]>> Architect System: Component Registry Loaded Successfully.[/dim]")
                    except Exception as e:
                        self.interface.print(f"[red]>> Architect Registry Error: {e}[/red]")

                    # Framework awareness based on DNA
                    variant = dna.get("variant") or "js"
                    framework = dna.get("framework") or "Modern JS"
                    framework_context = (
                        f"Detected Environment: Vite {framework} ({variant.upper()})."
                        if dna.get("is_vite")
                        else ""
                    )

                    # Extension mapping
                    ext = "tsx" if variant == "ts" else "jsx"
                    logic_ext = "ts" if variant == "ts" else "js"

                    # Force the model to output the PLAN.md file using the Nova protocol
                    task_instruction = (
                        f"1. Your goal is to write a comprehensive, implementation-ready PLAN.md based on the user request. {framework_context}\n"
                        "2. CRITICAL: DO NOT generate any [EDIT] blocks or application code. Your ONLY output must be the PLAN.md file.\n"
                        "3. You MUST output the plan using the exact tag: [CREATE: PLAN.md] followed by a triple-backtick Markdown code block.\n"
                        "4. TECHNOLOGY RULE: If the request is web-based, you MUST use HTML, CSS, and JS unless specifically told otherwise.\n"
                        f"5. VITE/MODULAR RULE: If the project is Vite, you MUST use {framework} with {variant.upper()}. Use .{ext} for components and .{logic_ext} for logic files. CRITICAL: Any file containing JSX/React hooks MUST use .{ext}, NEVER .ts/.js. CRITICAL: NEVER use `@/` path aliases. ALWAYS use explicit relative paths (e.g., `../components/`, `./utils.ts`). Define all file paths relative to the project root (e.g., 'src/components/Nav.{ext}', 'index.html'). CRITICAL TYPESCRIPT RULE: You MUST use `import type` for all React types (e.g. `import type {{ ReactNode }} from 'react';`) because verbatimModuleSyntax is enabled.\n"
                        "6. ASSEMBLY ORDER RULE: List files in dependency order. Lower-level components first. The main entry point MUST be the last file. CRITICAL: In the File Structure section, you MUST list EVERY file individually on its own line. Do NOT use summary folders like 'ui/ (atoms & molecules)'. Write out 'src/components/ui/ExpandableSidebar.tsx'. If you do not list the exact path, the build system will fail.\n"
                        "7. ROUTING & STYLING RULE: If using react-router-dom, <BrowserRouter> MUST wrap the outermost <App /> in main.tsx. CRITICAL: You MUST include a default root route (/) that redirects to the primary page (e.g., <Route path=\"/\" element={<Navigate to=\"/login\" replace />} />) to prevent blank screens. If using Tailwind, you MUST include postcss.config.js.\n"
                        "8. The plan must include: Objective, Architecture, Premium Components, File Structure, and Implementation Steps.\n"
                        "9. SCOPE LIMIT: DO NOT include CI/CD pipelines, GitHub Actions (.yml), unit testing (Vitest/Jest), Playwright, or Internationalization (i18n). Keep the plan STRICTLY focused on the frontend React UI components.\n"
                        "10. NO CODE HALLUCINATION: DO NOT output any [EDIT] tags or raw code implementations inside the PLAN.md."
                    )

                    if dna.get("is_vite") or is_web_query:
                        task_instruction += (
                            "\n11. AESTHETIC SELECTION (CRITICAL — defines the entire visual DNA):\n"
                            "You MUST select EXACTLY ONE aesthetic from the list below based on the business domain. (If building a SaaS/Finance app, strongly prefer E or D). "
                            "State your chosen aesthetic under a '## Design Aesthetic' heading in PLAN.md.\n"
                            "OPTIONS:\n"
                            "  A. NEO-BRUTALISM: Creative agencies, bold startups, music/art/streetwear. Thick borders, hard shadows, flat colors, zero blur.\n"
                            "  B. DARK LUXURY: Premium products, high-end audio, jewellery, spirits. Near-black bg, gold/amber accents, serif headings, cinematic animations.\n"
                            "  C. EDITORIAL MAGAZINE: Media, fashion, culture, lifestyle. Serif headings, asymmetric grid, black/white + one accent, dense typography.\n"
                            "  D. CLEAN MINIMALIST: Consultants, architects, portfolio, legal, finance. Extreme whitespace, thin fonts, restrained palette.\n"
                            "  E. CORPORATE SAAS: B2B software, dashboards, productivity. Rounded cards, blue/purple accents, tight professional spacing.\n"
                            "  F. PLAYFUL BOLD: Food, events, children, consumer apps. Saturated colors, rounded-3xl, bouncy animations, chunky fonts.\n"
                            "  G. DARK GLASSMORPHISM: ONLY for crypto, web3, AI tools, futuristic tech. Backdrop-blur, translucent panels, gradient mesh.\n"
                            "  H. ORGANIC NATURAL: Wellness, eco, beauty, food brands. Earthy tones, organic shapes, warm photography, serif fonts.\n\n"
                            "12. ANIMATION & PREMIUM COMPONENT MANDATE (CRITICAL): You MUST build the UI using the provided PREMIUM COMPONENT REGISTRY. "
                            "Do NOT hallucinate custom WebGL canvases (like NeonCanvas) or custom animation wrappers. "
                            "You MUST select an EXHAUSTIVE suite of components from the registry. For SaaS/Dashboards, you MUST pick components from the Navigation, Cards, Modals, and Elements categories. "
                            "CRITICAL: You MUST list your selected registry components in the 'File Structure' section of PLAN.md with the path `src/components/ui/<Component>.tsx`. If they are not in the File Structure, they will not be built! "
                            "You MUST include a section in PLAN.md formatted EXACTLY as follows:\n"
                            "## Premium Components Selected\n"
                            "- <Component 1 Name>\n"
                            "- <Component 2 Name>\n"
                            "- <Component 3 Name>\n"
                            "## Animation & Render Profile\n"
                            "- Core Motion Controller: GSAP ScrollTrigger, useGSAP, Framer Motion\n"
                            "- Global State: Zustand Store (for UI state/cart)\n"
                            "- Spring Physics Schema: <one of: gentle, snappy, bouncy, ultra-smooth cinematic>\n"
                            "- Scroll Timeline Reveal: <describe explicit target classes and offset boundaries>\n"
                            "- Micro-Interactions: <describe specific hover states like magnetic buttons>\n"
                            "13. IMAGE DENSITY MANDATE: DO NOT put binary files in the file structure. "
                            "If building a marketing site, specify EXACT image counts per section (Hero=1 full-bleed, Features=1 per card, Testimonials=1 avatar). "
                            "If building a SaaS or Dashboard app, specify images for User Avatars, Empty States, or Workspace thumbnails. "
                            "Provide 8-12 specific stock-photo filenames tied to the business domain "
                            "(e.g. 'financial-dashboard-dark.jpg', 'user-avatar-1.jpg'). "
                            "FORBIDDEN filenames: hero.jpg, bg.jpg, image.jpg, banner.jpg, photo1.jpg, or any generic web jargon.\n"
                            "14. DOMAIN TAG: Include a line formatted exactly as: `Domain Tag: <1-word-domain>` "
                            "(e.g. `Domain Tag: audio`). This is consumed by the asset pipeline."
                            f"{ui_ux_pro_max_context}"
                            f"{registry_context}"
                        )
                else:
                    # ANALYZE / GENERAL / READ / Default
                    phase_instruction = f"YOU ARE IN {intent} MODE."
                    if is_repo_overview:
                        task_instruction = (
                            "1. Analyze the provided REPOSITORY_MAP and full project context.\n"
                            "2. Provide a beautiful, highly detailed summary of the repository/folder.\n"
                            "3. Explain what files are present, the overall architecture, how components interconnect, and their relationships.\n"
                            "4. Use rich markdown formatting (headers, bullet points, bold text) to make it visually appealing and easy to digest."
                        )
                    else:
                        task_instruction = "1. Answer the user's question directly. Use the REPOSITORY_MAP to explain structure or logic if applicable."

                # If GENERAL intent, strip the repository map to save tokens and prevent content filter limits
                if intent == "GENERAL":
                    try:
                        from nova_cli.local.memory import get_relevant_memory_context
                        _memory_block = get_relevant_memory_context(os.getcwd(), _nova_memory_raw_prompt, top_k=5)
                        if not _memory_block or _memory_block == "[NO PRIOR MEMORY FOR THIS PROJECT]":
                            _memory_block = getattr(self, "project_memory_block", "[NO PRIOR MEMORY FOR THIS PROJECT]")
                    except Exception:
                        _memory_block = getattr(self, "project_memory_block", "[NO PRIOR MEMORY FOR THIS PROJECT]")
                    prompt_text = (
                        f"SYSTEM_INSTRUCTION: {phase_instruction}\n"
                        f"PROJECT_MEMORY (prior sessions on this project):\n{_memory_block}\n\n"
                        f"{task_instruction}\n\n"
                        f"USER_REQUEST: {prompt_text}"
                    )
                else:
                    prompt_text = (
                        f"SYSTEM_INSTRUCTION: {phase_instruction}\n"
                        f"REPOSITORY_MAP:\n{repo_map}\n\n"
                        f"{task_instruction}\n"
                        "2. DO NOT use native tool-calling. Use ONLY [CREATE], [EDIT], [MKDIR], [DELETE] tags if file changes are required.\n"
                        "3. CRITICAL: For [EDIT] or [CREATE], always follow with a Markdown code block.\n\n"
                        f"USER_REQUEST: {prompt_text}"
                    )

            # --- 3. API EXECUTION (STREAMING) ---
            # Technical logs are only shown for Coding or Build tasks per flowchart
            if (
                intent in ["EDIT", "CREATE", "BUILD"]
                or "PHASE: IMPLEMENTATION" in prompt_text
            ):
                self._step_start(f"Transmitting context to {target_model}...")
                total_bytes = sum(len(v) for v in self.state.loaded_files.values())
                self.interface.print(
                    f"[dim]  Payload size: {total_bytes / 1024:.1f} KB[/dim]"
                )
                self.interface.print(
                    f"[cyan]>> {target_model} is reasoning (Thinking)...[/cyan]"
                )

            # Switch to Streaming Response to show the Thinking Process
            active_basename = (
                os.path.basename(self.state.active_file)
                if self.state.active_file
                else None
            )

            # --- AUTO-CONTINUITY LOOP (Step 4 of Revamp) ---
            max_continuations = 3
            continuation_count = 0
            full_output = ""
            current_prompt = prompt_text
            current_context = self.state.loaded_files.copy()

            while continuation_count <= max_continuations:
                try:
                    # Prune context and repo_map for GENERAL intents to avoid payload size errors
                    safe_context = {} if intent == "GENERAL" else current_context
                    safe_repo_map = "" if intent == "GENERAL" else repo_map

                    if intent in ["GENERAL", "ANALYZE"]:
                        chunk_output = self.interface.stream_response(
                            api.chat_stream(
                                prompt=current_prompt,
                                context=safe_context,
                                model=target_model,
                                provider=target_provider,
                                repo_map=safe_repo_map,
                                active_file=active_basename,
                            )
                        )
                    else:
                        chunk_output = self.interface.stream_rich_response(
                            api.chat_stream(
                                prompt=current_prompt,
                                context=safe_context,
                                model=target_model,
                                provider=target_provider,
                                repo_map=safe_repo_map,
                                active_file=active_basename,
                            ),
                            show_reasoning=(continuation_count == 0),
                        )
                except RuntimeError as e:
                    # If a timeout occurs during a multi-part build, attempt a sub-retry
                    if "timeout" in str(e).lower() or "504" in str(e):
                        self._step_warn(
                            "Connection flickered. Attempting to recover stream..."
                        )
                        time.sleep(2)
                        continuation_count += 1
                        current_prompt = "CONTINUE_GENERATION: The connection was lost. Please resume exactly where you left off."
                        continue
                    raise e

                if continuation_count == 0:
                    full_output = chunk_output
                else:
                    from nova_cli.local.utils import stitch_code_blocks

                    full_output = stitch_code_blocks(full_output, chunk_output)

                # Check for truncation: Is there an unclosed code block?
                # A block is unclosed if triple backticks appear an odd number of times
                if full_output.count("```") % 2 != 0:
                    continuation_count += 1
                    self.interface.print(
                        f"[dim]  (Nova is extending the response... Part {continuation_count+1})[/dim]"
                    )
                    tail_snippet = full_output[-1500:]
                    current_prompt = (
                        f"ORIGINAL_USER_REQUEST:\n{prompt_text}\n\n"
                        "SYSTEM_OVERRIDE: Your previous response hit the token limit and was truncated mid-code block.\n"
                        "Here is the very end of your truncated output:\n"
                        f"...\n{tail_snippet}\n\n"
                        "TASK: Continue generating EXACTLY from the next character. "
                        "Do NOT write any preamble or explanations. "
                        "CRITICAL: When you finish writing the file, YOU MUST output the closing triple backticks (```) to properly close the code block."
                    )
                    current_context = self.state.loaded_files.copy()
                    continue
                else:
                    break

            output = full_output

            if not output:
                self._step_fail("No response received from AI.")
                return

            self.state.last_ai_response = output

            # Buffer extracted code for the ':apply' command
            extracted_code = extract_code_from_markdown(output)
            if extracted_code:
                self.state.last_generated_code = extracted_code

            # --- 4. LOCAL STATE SYNCHRONIZATION & INSPECTION ---
            # Execute [CREATE], [EDIT], [MKDIR], [DELETE] tags.
            modified_files = handle_ai_commands(output)

            # [INTEGRATED INSPECTION TURN]
            # If an edit or creation was made, verify it immediately.
            if isinstance(modified_files, list) and modified_files:
                broken_files = []
                for fpath in modified_files:
                    if fpath == "SYSTEM_ENVIRONMENT" or fpath.startswith("DELETED:"):
                        continue

                    from nova_cli.local.utils import check_syntax

                    is_valid, err = (
                        check_syntax(_read_text(fpath), fpath)
                        if os.path.exists(fpath)
                        else (True, "")
                    )
                    if not is_valid:
                        broken_files.append((fpath, err))

                if broken_files:
                    self._step_fail(
                        f"Inspection found {len(broken_files)} broken files."
                    )
                    # Construct a REWRITE request
                    rewrite_targets = ", ".join(
                        [os.path.basename(f[0]) for f in broken_files]
                    )
                    error_details = "\n".join(
                        [f"- {os.path.basename(f[0])}: {f[1]}" for f in broken_files]
                    )

                    rewrite_prompt = (
                        f"SYSTEM_OVERRIDE: DISREGARD PREVIOUS OUTPUT.\n"
                        f"The following files you just wrote are syntactically INVALID: {rewrite_targets}\n"
                        f"ERRORS:\n{error_details}\n\n"
                        f"TASK: Rewrite the COMPLETE content for these files from scratch using [CREATE] tags. "
                        f"Ensure no stray characters (like =, :, >) exist at the start of the file."
                    )

                    self._step_warn("Triggering automatic correction...")
                    # Recurse once to fix the files
                    return self.handle_ai_request(rewrite_prompt, nlp_intent="EDIT")

            # If the AI modified or created files, reload them into the persistent CLI context immediately.
            if isinstance(modified_files, list) and modified_files:
                for fpath in modified_files:
                    if fpath == "SYSTEM_ENVIRONMENT":
                        continue

                        # Handle Context Eviction for Deleted Files
                    if fpath.startswith("DELETED:"):
                        del_path = fpath.split("DELETED:", 1)[1].strip()
                        abs_del = os.path.abspath(del_path).replace("\\", "/")
                        base_del = os.path.basename(abs_del)

                        if base_del in self.state.loaded_files:
                            del self.state.loaded_files[base_del]
                        if base_del in self.state.loaded_paths:
                            del self.state.loaded_paths[base_del]
                        if self.state.active_file == abs_del:
                            self.state.active_file = None

                        self.interface.print(
                            f"[dim]>> Removed {base_del} from AI context.[/dim]"
                        )
                        continue

                    # Automated Discovery Sync: Normalize and track absolute paths
                    abs_path = os.path.abspath(fpath).replace("\\", "/")
                    if os.path.exists(abs_path):
                        self.state.active_file = abs_path
                        base = os.path.basename(abs_path)
                        try:
                            with open(abs_path, "r", encoding="utf-8") as f:
                                content = f.read()
                                self.state.loaded_files[base] = content
                                # CRITICAL: Map basename to absolute path for future Turn logic
                                self.state.loaded_paths[base] = abs_path
                        except Exception:
                            pass

                # --- POST-EDIT SYNC & EXECUTION ---
                if intent in ["EDIT", "CREATE"]:
                    self._step_start("Edits applied. Refreshing project context...")
                    new_context = run_contextify(config.PROJECT_ROOT, save_to_disk=True)
                    self.state.loaded_files.update(new_context)
                    self._step_ok("Project context updated.")

                    # Check if this is a Vite project
                    _is_vite_edit = any(
                        os.path.exists(os.path.join(config.PROJECT_ROOT, f))
                        for f in ["vite.config.ts", "vite.config.js"]
                    )

                    if _is_vite_edit:
                        # For Vite projects: launch dev server, never execute the edited file directly
                        import webbrowser as _wb
                        import subprocess as _sp

                        # Check if dev server is already running on 5173
                        _server_running = False
                        try:
                            import urllib.request as _ur
                            _ur.urlopen("http://localhost:5173", timeout=1)
                            _server_running = True
                        except Exception:
                            _server_running = False

                        if _server_running:
                            # Server already running — just open the browser
                            self.interface.print(
                                "[bold green]>> Edits applied. Dev server already running — opening browser...[/bold green]"
                            )
                            _wb.open("http://localhost:5173")
                        else:
                            # Start the dev server then open browser
                            self.interface.print(
                                "[bold green]>> Edits applied. Launching Vite dev server...[/bold green]"
                            )
                            _npm_cmd = "npm.cmd" if os.name == "nt" else "npm"
                            _sp.Popen(
                                [_npm_cmd, "run", "dev"],
                                cwd=config.PROJECT_ROOT,
                                stdout=_sp.DEVNULL,
                                stderr=_sp.DEVNULL,
                                creationflags=_sp.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
                                start_new_session=True if os.name != "nt" else False,
                                shell=(os.name == "nt"),
                            )

                            time.sleep(3)
                            _wb.open("http://localhost:5173")
                            self.interface.print(
                                "[green]>> http://localhost:5173 opened in browser.[/green]"
                            )
                    else:
                        # Non-Vite project: keep existing behavior, run the active file
                        if self.state.active_file:
                            self.interface.print(
                                f"[bold green]>> Edits applied successfully. Automatically running {os.path.basename(self.state.active_file)}...[/bold green]"
                            )
                            self.cmd_run(self.state.active_file)

                # --- PLAN COMPLETION GUIDANCE ---
                # Tell the user exactly what to do next so they don't get stuck
                if intent == "PLAN":
                    plan_check = os.path.join(os.getcwd(), "PLAN.md")
                    if os.path.exists(plan_check):
                        self.interface.print(
                            "\n[bold green]✓ PLAN.md created.[/bold green]"
                        )
                        self.interface.print(
                            "[cyan]>> Type [bold]build it[/bold] to start implementation with Kimi K2.6.[/cyan]\n"
                        )
                        if config.OVERDRIVE:
                            self.interface.print(
                                "[bold cyan]>> OVERDRIVE ENABLED: Starting implementation automatically...[/bold cyan]"
                            )

                            self.cmd_build_it("")

                        else:
                            self.interface.print(
                                "[cyan]>> Type [bold]build it[/bold] to start implementation with Kimi K2.6.[/cyan]\n"
                            )

            try:
                from nova_cli.local.memory import record_event
                _outcome_summary = (
                    f"{len(modified_files)} file(s) touched: {', '.join([str(f) for f in modified_files][:5])}"
                    if isinstance(modified_files, list) and modified_files
                    else "No files modified"
                )
                record_event(
                    cwd=os.getcwd(),
                    intent=intent,
                    request_summary=_nova_memory_raw_prompt[:300],
                    outcome=_outcome_summary,
                    files_changed=modified_files if isinstance(modified_files, list) else [],
                )
            except Exception:
                pass

            try:
                from nova_cli.local.memory import generate_and_store_note
                generate_and_store_note(
                    cwd=os.getcwd(),
                    intent=intent,
                    request_summary=_nova_memory_raw_prompt[:300],
                    outcome=_outcome_summary if isinstance(modified_files, list) else "No files modified",
                )
            except Exception:
                pass

            logging.info(
                f"Interaction | Model: {target_model} | Output Len: {len(output)}"
            )

            # Draw a divider after the conversation finishes
            self.interface.console.rule(style="dim")

        except Exception as e:
            # Display only the pretty panel to the user
            self.interface.display_error(str(e))

            # Record the technical details silently in the background file
            with open("nova.log", "a", encoding="utf-8") as f:
                timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
                f.write(f"{timestamp} - ERROR - Chat API Error: {str(e)}\n")

    def handle_ai_request_streaming_build(
        self, prompt_text, plan_content, override_model=None
    ):
        """
        Hybrid build path:
        1. Streams the implementation overview (visual process).
        2. Automatically extracts the file list from that stream.
        3. Builds files one-by-one to prevent 504 timeouts.
        """
        try:
            api = BridgeyeAPIClient()
            target_model = override_model or "moonshotai/kimi-k2.6"
            target_provider = "openrouter"
            self.interface.display_coding_mode(target_model)

            # Pre-install skipped: Dependencies are now dynamically calculated and installed based on the specific components selected.

            # --- PHASE 1: OVERVIEW STREAM ---
            self.interface.print(
                "[bold cyan]NOVA is planning the build sequence...[/bold cyan]"
            )

            # Refresh Peripheral Vision so AI sees existing files
            prompts.clear_file_tree_cache()
            repo_map = prompts.get_repo_map_cached(os.getcwd())

            is_web_plan = "react" in plan_content.lower() or "vite" in plan_content.lower() or "html" in plan_content.lower()
            web_rules = ""
            if is_web_plan:
                web_rules = (
                    "PREMIUM UI/UX MANDATE: You MUST use `framer-motion` for animations with VARIED patterns "
                    "(stagger, directional slides, spring physics, scale — never just opacity fade) "
                    "and `lucide-react` for icons.\n"
                    "IMAGE DENSITY MANDATE: Every section MUST include images. Required minimums: "
                    "Hero=1 full-bleed background image or video, "
                    "Features/Services=1 image per card (not just icon), "
                    "About/Team=1 photo per person + 1 office/workspace image, "
                    "Testimonials=1 avatar per testimonial, "
                    "Gallery/Portfolio=minimum 6 images in a grid, "
                    "Products=1 product image per item. "
                    "Do NOT leave any content section image-free.\n"
                    "IMAGE RULE: Never import images. Write <img> or <video> tags with hardcoded `/assets/filename.jpg` paths. "
                    "Filename MUST be 2-4 descriptive words specific to the business domain "
                    "(e.g. `/assets/wireless-headphones-dark.jpg`, `/assets/studio-mixing-board.jpg`). "
                    "NO generic names (hero.jpg, image1.jpg, bg.jpg, photo.jpg, banner.jpg). "
                    "Include a detailed photography prompt in the `alt` attribute "
                    "(e.g. `alt=\"Macro studio shot of glowing neon headphones on dark frosted glass, cinematic lighting\"`). "
                    "Include `data-image-type=\"hero|card|avatar|background|gallery\"` on every media tag. "
                    "ALWAYS apply `w-full h-full object-cover` Tailwind classes.\n"
                    "TYPESCRIPT RULE: If using TSX, use `import type { ReactNode } from 'react';` "
                    "to prevent verbatimModuleSyntax compiler errors.\n"
                )

            overview_prompt = (
                "PHASE: IMPLEMENTATION_OVERVIEW.\n"
                f"CURRENT PROJECT STRUCTURE:\n{repo_map}\n\n"
                "TASK: Briefly describe your implementation strategy and provide a list of ALL files required by PLAN.md.\n"
                "CRITICAL: If files already exist in the CURRENT PROJECT STRUCTURE, they must still be included in the list if they are part of the plan.\n"
                "CRITICAL EXTENSION RULE: If the project is React/Vite, you MUST autocorrect any files in the plan containing React components or Hooks to use the .jsx or .tsx extension. NEVER output a .js or .ts file for a component or hook.\n"
                "CRITICAL EXTRACTION RULE: Read the ENTIRE PLAN.md. If a file (like `index.html`, `tailwind.config.js`, or a `Context.tsx` file) is mentioned in the text/steps but was accidentally omitted from the File Structure tree, you MUST still include it in your output list.\n"
                "CRITICAL EXCLUSION RULE: DO NOT include `package.json`, lock files (`*lock*`), `tsconfig.json` (or any tsconfig variants), `vite.config.ts`, `eslint*`, `prettier*`, `.stylelintrc*`, `.gitignore`, `.vscode`, `README.md`, or `PLAN.md` in your list. Assume Vite already scaffolded the environment perfectly.\n"
                "MEDIA EXCLUSION RULE: You are a code generator. You CANNOT write binary files. DO NOT include any media assets (.jpg, .png, .mp4, .svg, .webp) in your file list, even if they are mentioned in the PLAN.md.\n"
                f"{web_rules}"
                "STRICT FORMAT FOR FILE LIST:\n"
                "Every CODE file you intend to create must be on its own line like this: [FILE] path/to/file.ext\n\n"
                f"PLAN.md:\n{plan_content}"
            )

            # Use the beautified streaming UI component
            try:
                full_overview = self.interface.stream_rich_response(
                    api.chat_stream(
                        overview_prompt,
                        self.state.loaded_files,
                        target_model,
                        target_provider,
                    )
                )
            except RuntimeError as e:
                self.interface.display_error(
                    f"{str(e)}\n\nCheck your internet and type [bold cyan]build it[/bold cyan] to try again."
                )
                return

            # --- PHASE 2: EXTRACT FILE LIST (Fail-Safe Extraction) ---
            # 1. High-Priority: Explicit Tags (We trust these 100%)
            tagged_files = re.findall(r"\[FILE\]\s*([\w\-\/\.]+)", full_overview)
            tagged_files.extend(
                re.findall(r"\[CREATE:\s*([\w\-\/\.]+)\]", full_overview)
            )

            # 2. Low-Priority Fallback: Markdown lists
            fallback_files = []
            lines = full_overview.splitlines()
            for line in lines:
                line = line.strip()
                # Matches "- path/file.ext" or "1. path/file.ext"
                m = re.search(r"(?:^[-*]\s*|^\d+\.\s*)([\w\-\/\.]+\.\w+)", line)
                if m:
                    fallback_files.append(m.group(1))

            # 3. Intelligent Filtering for Fallbacks
            # We only filter the fallback list to avoid technical terms like "Node.js"
            ignore_list = {
                "node.js",
                "express.js",
                "npm",
                "github",
                "express",
                "v14",
                "v16",
                "v18",
                "v20",
            }
            filtered_fallbacks = [
                f
                for f in fallback_files
                if f.lower() not in ignore_list
                and not f.lower().endswith((".md", ".txt"))
            ]

            # 4. Combine and Deduplicate
            # We prioritize tagged files, then add unique filtered fallbacks
            final_list = []
            seen = set()
            for f in tagged_files + filtered_fallbacks:
                f_norm = f.strip().replace("\\", "/")
                f_low = f_norm.lower()

                # CRITICAL: Ignore version numbers (e.g., 1.0, 2.4.1)
                if re.match(r"^\d+(\.\d+)+$", f_norm):
                    continue

                # CRITICAL: Ignore folder paths ending with a slash
                if f_norm.endswith("/"):
                    continue

                # CRITICAL EXCLUSIONS: Protect Vite's native scaffold and NOVA internal files
                forbidden = [
                    "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
                    "tsconfig.json", "tsconfig.node.json", "tsconfig.app.json", "vite.config.ts", "vite.config.js",
                    "eslint", "prettier", ".stylelintrc", ".gitignore",
                    ".github", ".vscode", ".husky", "readme.md", 
                    "plan.md", "project_context.txt", ".nova"
                ]
                if any(x in f_low for x in forbidden) or f_low.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp', '.ico', '.mp4', '.webm', '.svg')):
                    continue


                if f_low not in seen:
                    final_list.append(f_norm)
                    seen.add(f_low)

            files_to_build = final_list

            # --- REGISTRY AUTO-INJECTOR & DYNAMIC DEPENDENCIES ---
            import shutil
            import json
            import importlib.util as _auto_ilu
            import subprocess as _sp
            
            _auto_reg_base = None
            _auto_spec = _auto_ilu.find_spec("skills")
            if _auto_spec and _auto_spec.submodule_search_locations:
                _auto_reg_base = os.path.join(_auto_spec.submodule_search_locations[0], "frontend-web", "registry")
            
            if not _auto_reg_base or not os.path.exists(_auto_reg_base):
                _rt4 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                _auto_reg_base = os.path.join(_rt4, "skills", "frontend-web", "registry")
                if not os.path.exists(_auto_reg_base):
                    _rt3 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
                    _auto_reg_base = os.path.join(_rt3, "skills", "frontend-web", "registry")

            _injected_any = False
            _files_to_keep = []
            _dynamic_deps = {"clsx", "tailwind-merge", "framer-motion", "lucide-react", "react-router-dom"}
            _registry_usage_context = []
            
            # Load registry index for usage examples
            _reg_index = {}
            if _auto_reg_base:
                _idx_path = os.path.join(_auto_reg_base, "registry_index.json")
                if os.path.exists(_idx_path):
                    try:
                        with open(_idx_path, "r", encoding="utf-8") as _rf:
                            _raw_idx = json.load(_rf)
                            for _cat, _items in _raw_idx.items():
                                if isinstance(_items, list):
                                    for _item in _items:
                                        if isinstance(_item, dict) and "name" in _item:
                                            _reg_index[_item["name"].lower()] = _item
                    except: pass
            
            for fpath in files_to_build:
                _fname = os.path.basename(fpath)
                _fname_no_ext = os.path.splitext(_fname)[0]
                _was_injected = False
                
                if _auto_reg_base and os.path.exists(_auto_reg_base):
                    for root_dir, _, files in os.walk(_auto_reg_base):
                        for reg_file in files:
                            if os.path.splitext(reg_file)[0] == _fname_no_ext:
                                _src_path = os.path.join(root_dir, reg_file)
                                _target_path = os.path.join(config.PROJECT_ROOT, os.path.dirname(fpath), reg_file)
                                os.makedirs(os.path.dirname(_target_path), exist_ok=True)
                                shutil.copy(_src_path, _target_path)
                                self.interface.print(f"[bold magenta]>> Injected Premium Component:[/bold magenta] [dim]{reg_file}[/dim]")
                                
                                # Extract dependencies via regex
                                try:
                                    with open(_src_path, "r", encoding="utf-8") as _sf:
                                        _content = _sf.read()
                                        _imports = re.findall(r"(?:import|from)\s+['\"]([^'\"]+)['\"]", _content)
                                        for _imp in _imports:
                                            if not _imp.startswith(".") and not _imp.startswith("@/"):
                                                _pkg = "/".join(_imp.split("/")[:2]) if _imp.startswith("@") else _imp.split("/")[0]
                                                if _pkg not in ["react", "react-dom"]:
                                                    _dynamic_deps.add(_pkg)
                                except: pass
                                
                                # Add usage example to context
                                _reg_data = _reg_index.get(_fname_no_ext.lower())
                                if _reg_data and "usage_example" in _reg_data:
                                    _registry_usage_context.append(f"COMPONENT: {_fname_no_ext}\nUSAGE PROPS:\n```tsx\n{_reg_data['usage_example']}\n```")
                                
                                _injected_any = True
                                _was_injected = True
                                break
                        if _was_injected:
                            break
                
                if not _was_injected:
                    _files_to_keep.append(fpath)
            
            files_to_build = _files_to_keep
            
            if _injected_any and _auto_reg_base:
                _utils_src = os.path.join(_auto_reg_base, "core", "utils.ts")
                _utils_target = os.path.join(config.PROJECT_ROOT, "src", "components", "core", "utils.ts")
                if os.path.exists(_utils_src):
                    os.makedirs(os.path.dirname(_utils_target), exist_ok=True)
                    shutil.copy(_utils_src, _utils_target)
                files_to_build = [f for f in files_to_build if not f.endswith("utils.ts")]
                
                # Run dynamic dependency installation
                _pkg_json_path = os.path.join(config.PROJECT_ROOT, "package.json")
                if os.path.exists(_pkg_json_path) and _dynamic_deps:
                    # FIX: Lucide removed brand icons in recent versions. Pin to v0.330.0 to prevent registry component crashes.
                    _deps_to_install = ["lucide-react@0.330.0" if d == "lucide-react" else d for d in _dynamic_deps]
                    self.interface.print(f"[dim]>> Installing dynamic dependencies: {', '.join(_deps_to_install)}[/dim]")
                    _npm_cmd = "npm.cmd" if os.name == "nt" else "npm"
                    _sp.run([_npm_cmd, "install", "--save"] + _deps_to_install, cwd=config.PROJECT_ROOT, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, shell=(os.name == "nt"))

            # Store the usage context in state so the build loop can access it
            self.state.registry_usage_context = "\n\n".join(_registry_usage_context)

            # Prepend design system foundation files (animations.ts + tokens.ts) for Vite builds
            _is_vite_build = any(
                os.path.exists(os.path.join(config.PROJECT_ROOT, f))
                for f in ["vite.config.ts", "vite.config.js"]
            )
            if _is_vite_build and files_to_build:
                _anim_present = any('animations' in f for f in files_to_build)
                _tok_present  = any('token' in f.lower() for f in files_to_build)
                _foundation   = []
                if not _anim_present:
                    _foundation.append('src/animations.ts')
                if not _tok_present:
                    _foundation.append('src/tokens.ts')
                if _foundation:
                    files_to_build = _foundation + [
                        f for f in files_to_build
                        if f not in ('src/animations.ts', 'src/tokens.ts')
                    ]
                    self.interface.print(f"[dim]>> Design system foundation prepended: {', '.join(_foundation)}[/dim]")

            if not files_to_build:
                self._step_fail(
                    "No files identified for build. Please check implementation overview."
                )
                return

            self._step_ok(
                f"Identified {len(files_to_build)} unique files to build sequentially."
            )

            # Save build state for potential :continue resumption
            self.state.pending_build_files = files_to_build
            self.state.current_plan_content = plan_content
            self.state.save_build_state()

            # Trigger the shared execution loop with the selected model
            self._execute_build_loop(override_model=target_model)

        except Exception as e:
            self.interface.print(f"[bold red]Build API Error:[/bold red] {e}")
            logging.error(f"Hybrid Build Error: {e}")

    def should_use_prompt_enhancer(self, prompt_text: str, intent: str) -> bool:
        """
        Mandatory gate for the Architect phase.
        """
        plan_exists = os.path.exists(os.path.join(os.getcwd(), "PLAN.md"))
        is_lab_env = os.path.exists("experiments/research_log.md")
        lower_p = prompt_text.lower()

        # Rule: Data Science sessions bypass the architect gate for report/analysis follow-ups
        reactive_keywords = [
            "report",
            "plot",
            "table",
            "graph",
            "analysis",
            "result",
            "value",
            "chart",
        ]
        if is_lab_env and any(k in lower_p for k in reactive_keywords):
            return False

        # Skip for resumption/meta commands
        if any(k in lower_p for k in ["start where", "start from", "continue"]):
            return False

        # Skip for system internal calls or very short inputs
        if "SYSTEM_OVERRIDE" in prompt_text or len(prompt_text.strip()) < 5:
            return False

        # If we are explicitly building, we don't enhance the prompt
        if intent == "BUILD":
            return False

        # RULE: If no PLAN.md exists, any intent to CREATE or PLAN must go through the Enhancer
        if not plan_exists and intent in ["PLAN", "CREATE"]:
            return True

        # RULE: If user explicitly asks for a re-plan
        if intent == "PLAN":
            replan_keywords = [
                "new plan",
                "redo plan",
                "update plan",
                "replan",
                "start over",
                "scrap the plan",
            ]
            if any(kw in prompt_text.lower() for kw in replan_keywords):
                return True

        return False

    def run_prompt_enhancement_flow(self, user_prompt: str) -> str | None:
        """
        Enhances the user's raw prompt before execution.
        Allows the user to:
        1. approve
        2. suggest edits
        3. cancel

        Returns:
            - final approved prompt as str
            - None if cancelled or failed
        """
        api = BridgeyeAPIClient()

        current_enhanced_prompt = None
        edit_request = None

        while True:
            try:
                with self.interface.create_loader(
                    "Enhancing and completing your instructions to make them more executable..."
                ):
                    response = api.enhance_prompt(
                        user_prompt=user_prompt,
                        model=self.model_name,
                        provider=self.provider,
                        current_enhanced_prompt=current_enhanced_prompt,
                        edit_request=edit_request,
                    )
            except Exception as e:
                err_str = str(e)
                title = "Provider Outage" if "connectivity issues" in err_str else "Prompt Enhancer Error"
                self.interface.display_error(err_str, title=title)
                return None

            enhanced_prompt = extract_enhanced_prompt(
                (response or {}).get("enhanced_prompt", "")
            )
            if not enhanced_prompt:
                self.interface.display_error("Empty enhanced prompt received.", title="Prompt Enhancer Error")
                return None

            self.interface.print("\n[bold cyan]Enhanced Prompt:[/bold cyan]")
            self.interface.print(Panel(enhanced_prompt, border_style="cyan"))

            self.interface.print("\n[bold yellow]Choose an option:[/bold yellow]")
            self.interface.print("[green]1.[/green] Approve and continue")
            self.interface.print("[green]2.[/green] Suggest edits")
            self.interface.print("[green]3.[/green] Cancel")

            choice = self.interface.input("[cyan]Enter choice > [/cyan]").strip()

            if choice == "1":
                return enhanced_prompt

            if choice == "3":
                self.interface.print("[dim]Prompt enhancement cancelled.[/dim]")
                return None

            if choice == "2":
                edit_request = self.interface.input(
                    "[cyan]Enter what you want changed in the enhanced prompt > [/cyan]"
                ).strip()

                if not edit_request:
                    self.interface.print(
                        "[yellow]No edit request entered. Showing current enhanced prompt again.[/yellow]"
                    )
                    edit_request = None
                    continue

                current_enhanced_prompt = enhanced_prompt
                continue

            self.interface.print(
                "[yellow]Invalid choice. Please enter 1, 2, or 3.[/yellow]"
            )
            edit_request = None

    # _get_nlp_intent has been removed. Intent is now handled via api.classify_intent()

    def _execute_build_loop(self, override_model=None):
        """Core incremental generator loop with state tracking and 429 retries."""
        # [FIX]: Ensure Vite Type Definitions exist before building
        # This resolves CSS Module and Asset import errors in TypeScript
        v_env_path = os.path.join(config.PROJECT_ROOT, "src", "vite-env.d.ts")
        if not os.path.exists(v_env_path):
            try:
                os.makedirs(os.path.dirname(v_env_path), exist_ok=True)
                with open(v_env_path, "w", encoding="utf-8") as f:
                    f.write('/// <reference types="vite/client" />\n')
                self.interface.print(
                    "[dim]>> Environment: Created src/vite-env.d.ts for Type Safety.[/dim]"
                )
            except Exception:
                pass

        api = BridgeyeAPIClient()
        target_model = override_model or "moonshotai/kimi-k2.6"
        target_provider = "openrouter"

        # Inject Web Rules directly into the build loop so Kimi doesn't forget them while coding
        plan_content = self.state.current_plan_content or ""
        is_web_plan = "react" in plan_content.lower() or "vite" in plan_content.lower() or "html" in plan_content.lower()

        # --- GAP 1 FIX: Aesthetic extraction to drive dynamic styling rules ---
        _aesthetic_match = re.search(
            r'(?:design\s+aesthetic|aesthetic|theme|visual\s+(?:language|style|direction))[:\s*#]+([^\n]{5,100})',
            plan_content, re.IGNORECASE
        )
        _detected_aesthetic = _aesthetic_match.group(1).strip().lower() if _aesthetic_match else ""

        _AESTHETIC_RULES = {
            "neo-brutalism": (
                "Use thick solid borders (border-2 or border-4, black or white). "
                "Hard drop-shadows with no blur: className='shadow-[4px_4px_0px_#000]'. "
                "rounded-none everywhere — NO rounded corners, NO backdrop-blur, NO glassmorphism. "
                "Bold flat colors, no gradients. font-black or font-extrabold for all headings."
            ),
            "minimalist": (
                "Use extreme whitespace: py-32 between sections, gap-16 in grids. "
                "Thin typography: font-light or font-thin for large display headings. "
                "No shadows. Border border-gray-100 or border-zinc-800 for subtle dividers only. "
                "One accent color used sparingly. Clean asymmetric grid layouts."
            ),
            "glassmorphism": (
                "Use backdrop-blur-xl consistently across all cards and panels. "
                "bg-white/5 or bg-black/20 with border border-white/10. rounded-2xl on all cards. "
                "Layer translucent panels over rich gradient or image backgrounds."
            ),
            "editorial": (
                "Use large serif headings (font-serif, text-7xl or text-8xl for hero). "
                "Asymmetric CSS grid layouts. Black and white primary palette with exactly ONE accent color. "
                "Dense column text with generous leading-relaxed or leading-loose."
            ),
            "dark luxury": (
                "Use near-black backgrounds with arbitrary Tailwind values (bg-[#0a0a0f]). "
                "Gold or amber accents: text-amber-400, border-amber-400/30. "
                "Pair elegant serif heading font with light sans-serif body. "
                "Subtle gradient overlays: bg-gradient-to-b from-transparent to-black/60. No glassmorphism."
            ),
            "corporate saas": (
                "Use rounded-xl cards with clean subtle shadows (shadow-sm or shadow-md). "
                "Blue or purple primary accents (blue-600, violet-600). "
                "Tight professional spacing: py-16 for sections, gap-8 for grids. "
                "Clean sans-serif typography, no decorative fonts."
            ),
            "playful": (
                "Use bright saturated colors with high contrast. rounded-3xl on all cards. "
                "Overlapping elements and rotated decorative shapes (rotate-3, -rotate-6). "
                "Bold chunky fonts (font-extrabold, text-5xl+). "
                "Scale and rotate hover effects: hover:scale-105 hover:rotate-1."
            ),
            "organic": (
                "Use warm earthy tones (stone, amber, green Tailwind palette). "
                "Soft rounded shapes (rounded-2xl) with organic asymmetry. "
                "Warm serif typography paired with a humanist sans-serif body font."
            ),
        }

        _aesthetic_rule = next(
            (rule for key, rule in _AESTHETIC_RULES.items() if key in _detected_aesthetic),
            (
                "Read the Design Aesthetic section in PLAN.md and apply its specific Tailwind patterns "
                "consistently across ALL files. DO NOT default to glassmorphism unless the plan "
                "explicitly names it. Use the spacing, border style, shadow depth, and color palette "
                "from the plan on every component without deviation."
            )
        )

        # --- GAP 4 FIX: Animation Profile extraction (drives per-file motion specifics) ---
        def _extract_plan_field(label):
            _m = re.search(rf'-\s*{label}\s*:\s*([^\n]+)', plan_content, re.IGNORECASE)
            return _m.group(1).strip() if _m else ""

        _animation_profile = {
            "spring_style": _extract_plan_field("Spring Style"),
            "hero_entry": _extract_plan_field("Hero Entry"),
            "section_entry": _extract_plan_field("Section Entry"),
            "hover_style": _extract_plan_field("Hover Style"),
        }
        _animation_profile_block = (
            (
                "PLAN-SPECIFIED ANIMATION PROFILE (apply these exact values when building animations.ts and section components):\n"
                f"- Spring Style: {_animation_profile['spring_style'] or 'not specified, infer from aesthetic'}\n"
                f"- Hero Entry: {_animation_profile['hero_entry'] or 'not specified, infer from aesthetic'}\n"
                f"- Section Entry: {_animation_profile['section_entry'] or 'not specified, infer from aesthetic'}\n"
                f"- Hover Style: {_animation_profile['hover_style'] or 'not specified, infer from aesthetic'}\n"
            )
            if any(_animation_profile.values()) else ""
        )

        # --- GAP 6 FIX: Animation variety extraction ---
        _ANIMATION_RULES = {
            "neo-brutalism": (
                "Use abrupt snap animations: initial={{scale: 0.96}} animate={{scale: 1}} "
                "transition={{ease: 'backOut', duration: 0.15}}. "
                "Elements pop in with scale only — no opacity fade. "
                "whileTap={{scale: 0.94}} on all interactive elements."
            ),
            "minimalist": (
                "Use very slow subtle fades: initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} "
                "transition={{duration:0.8, ease:'easeOut'}}. "
                "staggerChildren: 0.2 for generous delays. Never use rotation or scale transforms."
            ),
            "glassmorphism": (
                "Fade up with blur: initial={{opacity:0, y:20, filter:'blur(8px)'}} "
                "animate={{opacity:1, y:0, filter:'blur(0px)'}} transition={{duration:0.6}}. "
                "Spring hover: whileHover={{scale:1.02}} transition={{type:'spring', stiffness:300, damping:20}}."
            ),
            "editorial": (
                "Use horizontal reveal: initial={{x:-60, opacity:0}} animate={{x:0, opacity:1}} for left-column text. "
                "Image blocks: initial={{x:60, opacity:0}} animate={{x:0, opacity:1}}. "
                "staggerChildren: 0.04 for tight line-by-line text reveals."
            ),
            "dark luxury": (
                "Use slow cinematic entrances: initial={{opacity:0, y:30}} animate={{opacity:1, y:0}} "
                "transition={{duration:1.2, ease:[0.25,0.46,0.45,0.94]}}. "
                "Accent elements pulse: animate={{opacity:[0.5,1,0.5]}} transition={{repeat:Infinity, duration:3}}."
            ),
            "playful": (
                "Use bouncy spring everywhere: transition={{type:'spring', stiffness:400, damping:10}}. "
                "Cards: initial={{y:40, rotate:-2}} animate={{y:0, rotate:0}}. "
                "whileHover={{rotate:3, scale:1.05}}. whileTap={{scale:0.88}} on all buttons."
            ),
        }

        _animation_rule = next(
            (rule for key, rule in _ANIMATION_RULES.items() if key in _detected_aesthetic),
            (
                "Vary animation direction per section: alternate y:30, x:-40, x:40 entries across sections. "
                "Use spring physics (type:'spring', stiffness:260, damping:20) on hover states. "
                "staggerChildren:0.08 on all grids and lists. "
                "Never use identical animation on two consecutive sections."
            )
        )

        # --- GAP 7 FIX: Design token extraction to lock visual consistency ---
        _color_match = re.search(
            r'(?:palette|colors?|color\s+system)[:\s]+([^\n#]{20,300})',
            plan_content, re.IGNORECASE | re.DOTALL
        )
        _font_match = re.search(
            r'(?:fonts?|typography)[:\s]+([^\n#]{10,200})',
            plan_content, re.IGNORECASE
        )
        _design_tokens = []
        if _color_match:
            _design_tokens.append(f"COLORS: {_color_match.group(1).strip()[:200]}")
        if _font_match:
            _design_tokens.append(f"TYPOGRAPHY: {_font_match.group(1).strip()[:150]}")
        _design_token_block = (
            "LOCKED DESIGN TOKENS (apply these EXACTLY across all components — zero deviation):\n" +
            "\n".join(_design_tokens)
        ) if _design_tokens else ""

        build_web_rules = ""
        if is_web_plan:
            _reg_usage = getattr(self.state, "registry_usage_context", "")
            _reg_block = f"\n\nREGISTRY USAGE EXAMPLES (CRITICAL PROP MAPPING):\n{_reg_usage}\n" if _reg_usage else ""
            build_web_rules = (
                "\n\nWEB BUILD RULES (CRITICAL):\n"
                "1. GLOBAL LAYOUT SHELL (SPA ROUTING): You MUST create a `src/components/layout/Layout.tsx` (or similar) that houses the Navbar and Footer, with an `<Outlet />` (from react-router-dom) in the middle for page content. `App.tsx` MUST wrap this Layout in `<BrowserRouter>` and `<AnimatePresence>`. NEVER import the Navbar or Footer directly into individual page components like `Home.tsx`.\n"
                "2. CORE UTILS LOCK: The file `src/components/core/utils.ts` is pre-generated. You are FORBIDDEN from generating or modifying it. Just import `cn` from it using relative paths.\n"
                "3. MEDIA RULE: NEVER output `[CREATE: ...]` tags for .mp4, .jpg, or .png files. You are a code generator. You must ONLY write `<img>` or `<video>` tags INSIDE your UI components. CRITICAL: DO NOT use 'import' statements for images/videos at the top of the file. You MUST use hardcoded absolute path strings directly in the src attribute (e.g., `<img src=\"/assets/studio-amplifier.jpg\" />` or `<video src=\"/assets/abstract-waves.mp4\" />`).\n"
                "4. ASSET NAMING: The filename in the src attribute is sent directly to a stock API. It MUST be a 1-3 word literal visual subject highly relevant to the business domain (e.g., `/assets/wireless-headphones.jpg`). ABSOLUTELY NO WEB JARGON (`hero.mp4`, `bg.jpg`). If the PLAN.md asks you to create a media file, IGNORE IT and only reference it in the code.\n"
                "5. IMAGE ATTRIBUTES: You MUST include `data-image-type=\"hero|card|icon|avatar|background\"` on every <img> and <video> tag to define its orientation. You MUST also include a highly detailed photography prompt in the `alt` attribute (e.g., `alt=\"Macro shot of glowing neon audio equipment\"`). ALWAYS apply Tailwind classes like `w-full h-full object-cover` to prevent layout breaking.\n"
                "6. TYPESCRIPT RULE: If using TSX, you MUST use `import type { ReactNode } from 'react';` to prevent verbatimModuleSyntax compiler errors. Do NOT use deep imports for NPM packages. CRITICAL: DO NOT import `Object3DNode` from `@react-three/fiber` (it crashes the browser). If you use `useGSAP`, bypass TS dependency errors with `// @ts-ignore` rather than creating complex type interfaces.\n"
                "7. ANTI-BOILERPLATE RULE: The default Vite boilerplate files (`react.svg`, `vite.svg`, `App.css`) have been DELETED from the environment. You are STRICTLY FORBIDDEN from importing them in `App.tsx` or `main.tsx`. Write entirely custom logic.\n"
                f"8. UI/UX CONSISTENCY RULE: {_aesthetic_rule} Maintain the EXACT same spacing scale, border style, shadow depth, and color tokens from PLAN.md across ALL components ΓÇö no aesthetic drift between files.\n"
                "9. CROSS-FILE CONTINUITY: When importing components or interfaces generated in previous steps, carefully read the PROJECT CONTEXT to match exact export names and props. Do not hallucinate prop names.\n"
                f"10. ANIMATION SYSTEM: {_animation_rule} Use staggered entry animations on all grids and lists (staggerChildren + delayChildren). Never use the same motion pattern on two consecutive sections.\n"
                f"{_reg_block}"
            )

        # Use a copy for safe iteration while modifying original state
        files_to_process = list(self.state.pending_build_files)
        total_files = len(files_to_process)
        final_built_files = []

        for i, fpath in enumerate(files_to_process, 1):
            last_syntax_error = ""  # [FIX]: Initialize for the rewrite prompt
            # Safety Check: Skip invalid "filenames" that are just numbers or known false positives
            if re.match(r"^\d+(\.\d+)+$", fpath) or fpath.lower() in [
                "node.js",
                "express.js",
                "npm",
            ] or fpath.endswith("/") or os.path.isdir(fpath) or fpath.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp', '.ico', '.mp4', '.webm', '.svg')):
                if fpath in self.state.pending_build_files:
                    self.state.pending_build_files.remove(fpath)
                continue

            # [CORE SYNC] Update context from disk before every file build turn
            # Scanning config.PROJECT_ROOT ensures AI sees both src/ and root/ boundary files
            self._step_start(f"Synchronizing project context for {fpath}...")
            new_ctx = run_contextify(config.PROJECT_ROOT, save_to_disk=True)
            self.state.loaded_files.update(new_ctx)
            for rel_p in new_ctx.keys():
                abs_p = os.path.abspath(rel_p).replace("\\", "/")
                self.state.loaded_paths[os.path.basename(rel_p)] = abs_p

            # Refresh Peripheral Vision Map
            prompts.clear_file_tree_cache()
            repo_map = prompts.get_repo_map_cached(os.getcwd())
            self._step_ok("Context updated.")

            # Rate-limit prevention: Small pause between high-compute requests
            if i > 1:
                time.sleep(2)

            file_output = ""
            max_retries = 5
            success = False

            for attempt in range(max_retries):
                try:
                    self.interface.print(
                        f"\n[bold cyan]─── [{i}/{total_files}] Constructing: {fpath} ───[/bold cyan]"
                    )

                    # Determine if this is a first-time build or a rewrite attempt
                    if attempt == 0:
                        # Determine file-specific animation instruction
                        _fname_lower    = fpath.lower()
                        _is_anim_file   = 'animations' in _fname_lower and _fname_lower.endswith('.ts')
                        _is_token_file  = 'token' in _fname_lower and _fname_lower.endswith('.ts')
                        _is_app_tsx     = _fname_lower.endswith(('app.tsx', 'app.jsx'))
                        _is_section_cmp = is_web_plan and any(
                            kw in _fname_lower for kw in [
                                'hero', 'feature', 'testimonial', 'pricing', 'about',
                                'contact', 'faq', 'cta', 'service', 'gallery', 'team',
                                'footer', 'navbar', 'header', 'menu', 'stats', 'banner',
                                'landing', 'home', 'page', 'dashboard', 'view', 'layout'
                            ]
                        )

                        if _is_anim_file:
                            _file_anim_rule = (
                                "\nANIMATION UTILS RULE: Generate src/animations.ts ΓÇö housing both Framer Motion presets AND global GSAP orchestration configurations. "
                                "You MUST export functional hook setups or configuration setups for `@gsap/react` alongside classic Framer Motion variants. "
                                "Ensure GSAP easing defaults use custom bezier smooth parameters like `power4.out`, `expo.out`, or custom elastic weight mappings. "
                                "CRITICAL EXPORT RULE: AI models often hallucinate animation names. You MUST export `fadeUp`, `fadeLeft`, `fadeRight`, `scaleIn`, `stagger`, `hoverLift`, and `hoverScale`. To prevent crashes, you MUST also export aliases for common hallucinations at the bottom of the file: `export const fadeInUp = fadeUp; export const fadeInLeft = fadeLeft; export const fadeInRight = fadeRight; export const fadeInDown = fadeDown;`. "
                                "Include explicit global helper routines for magnetic tracking elements or smooth scroll timeline binds."
                                f"\n{_animation_profile_block}"
                            )
                        elif 'canvas' in _fname_lower or 'shader' in _fname_lower:
                            _file_anim_rule = (
                                "\nWEBGL CANVAS COMPONENT RULE (CRITICAL): You are generating a high-performance 3D or shader element using `@react-three/fiber` and `@react-three/drei`. "
                                "1. If using custom GLSL shaders, declare `shaderMaterial` from `@react-three/drei` outside the component loop and use `extend()` to register it as a native JSX tag. "
                                "2. Pass real-time time matrices (`uTime`) or pointer offsets (`uMouse`) as uniforms. Update `uTime` on every hardware refresh frame inside a strict `useFrame()` execution block. "
                                "3. Smooth pointer variations using dynamic vector interpolations (`.lerp()`) to ensure interactions feel silky and organic, matching a luxury aesthetic. "
                                "4. Never mix HTML `<div>` or layout tags directly inside the `<Canvas>`. Use standard Three elements like `<mesh>`, `<planeGeometry>`, `<bufferGeometry>`, or custom mesh materials. "
                                "5. TYPESCRIPT CRITICAL: DO NOT import `Object3DNode` from `@react-three/fiber`! It is deprecated and crashes Vite. Use standard JSX `<mesh>` tags. If TS complains about custom shader tags in JSX, add `// @ts-ignore` above them."
                        )
                        elif _is_token_file:
                            _file_anim_rule = (
                                "\nTOKEN FILE RULE: Generate src/tokens.ts — single source of truth for design tokens. "
                                "Populate from PLAN CONTEXT palette and font values. "
                                "Structure: color (primary/secondary/accent/background/surface/text/muted/border), "
                                "font (heading/body), spacing (section/container/card/gap Tailwind strings), "
                                "radius (sm/md/lg/xl Tailwind strings), shadow (sm/md/lg/hover Tailwind strings). "
                                "Export as: export const tokens = { ... } as const;"
                            )
                        elif _is_app_tsx:
                            _file_anim_rule = (
                                "\nAPP.TSX CORE SHELL RULE (CRITICAL): You MUST architect a persistent multi-layered global layout container with bridged context. "
                                "1. Mount the WebGL `<Canvas>` at the absolute root (`z-0`) to prevent context eviction. Route hierarchies must sit above it at `z-10`. "
                                "2. Create and integrate a global state manager (e.g., Zustand) to bridge data between the React Router DOM and the R3F `<Canvas>`, solving the Context Isolation Barrier. "
                                "3. Implement a global scroll listener on the HTML wrapper that writes scroll progress to the global store. R3F `useFrame` hooks must read this to sync shader uniforms and camera positions, solving the Scroll Syncing Disconnect. "
                                "4. Include a `SceneManager` component inside the Canvas that listens to global route state to seamlessly orchestrate GSAP timeline transitions between 3D assets on route swaps, preventing abrupt cuts or memory bloat. "
                                "5. Implement standard `<AnimatePresence mode='wait'>` over the HTML route stack."
                            )
                        elif _is_section_cmp:
                            _file_anim_rule = (
                                "\nSECTION ANIMATION RULE (CRITICAL): Use scroll-triggered animations via useInView. "
                                "import { motion, useInView } from 'framer-motion'; import { useRef } from 'react'; "
                                "import { fadeUp, fadeLeft, fadeRight, scaleIn, stagger, hoverLift, hoverScale } from '../animations'; "
                                "const ref = useRef(null); const isInView = useInView(ref, { once: true, margin: '-80px' }); "
                                "Outer section: <motion.section ref={ref} variants={stagger()} initial='hidden' animate={isInView ? 'show' : 'hidden'}> "
                                "Each heading/paragraph/card MUST have a motion.* wrapper with a named variant from animations.ts. "
                                "CRITICAL ANTI-HALLUCINATION: Do NOT use names like 'fadeInLeft' or 'fadeInUp'. You MUST use exactly 'fadeLeft', 'fadeRight', 'fadeUp'. "
                                "Alternate directions: left-side text uses fadeLeft, right-side image uses fadeRight, cards use fadeUp. "
                                "Card hover: whileHover={hoverLift}. CTA buttons: whileHover={hoverScale}. "
                                "Exception: if this is a Navbar or Footer, use mount-only animation (initial='hidden' animate='show'), no useInView."
                                f"\n{_animation_profile_block}"
                            )
                        else:
                            _file_anim_rule = (
                                "\nANIMATION IMPORT RULE: If this file contains interactive UI elements, "
                                "import { motion } from 'framer-motion' and use named variants from '../animations'. "
                                "Never write inline animation values."
                            )

                        build_prompt = (
                            f"PHASE: IMPLEMENTATION. Generate the complete code for: {fpath}\n"
                            f"PLAN CONTEXT:\n{self.state.current_plan_content}\n"
                            f"{_design_token_block}\n"
                            f"{_file_anim_rule}\n"
                            "PREMIUM COMPONENT RULE: If the PLAN CONTEXT mentions registry components (e.g., Ferrofluid, BlurText, HeroGeometric), they ALREADY EXIST in `src/components/ui/`. You MUST import them using relative paths. CRITICAL: Do NOT recreate or edit the raw code of these components. You MUST adapt them to the brand by passing the chosen palette colors and business-specific text directly into their props (e.g., `<HeroGeometric title1=\"Your Brand\" colors={{['#000000', '#FF0055']}} />`).\n"
                            f"STRICT: Output ONLY the [CREATE: {fpath}] tag followed by a Markdown code block.\n"
                            "CRITICAL: DO NOT include docstrings, comments, thoughts, or explanations. The resulting file must contain ONLY valid, functional code for the target language.\n"
                            "CRITICAL CASING RULE: Import paths are STRICTLY CASE-SENSITIVE. Ensure the capitalization of your imports exactly matches the folder/file names defined in the PLAN CONTEXT.\n"
                            "CRITICAL ANTI-BOILERPLATE: You are FORBIDDEN from importing `App.css`, `react.svg`, or `vite.svg`. They do not exist. If building `App.tsx` or `main.tsx`, write custom logic from scratch."
                            f"{build_web_rules}"
                        )
                    else:
                        build_prompt = (
                            f"REWRITE_REQUIRED: Your previous attempt at {fpath} was syntactically invalid.\n"
                            f"ERROR DETECTED: {last_syntax_error}\n"
                            f"TASK: Re-read the PLAN and generate the FULL, valid file content for {fpath} from scratch. Do not repeat the error.\n"
                            "CRITICAL ANTI-BOILERPLATE: You are FORBIDDEN from importing `App.css`, `react.svg`, or `vite.svg`."
                        )

                    # Use stream_rich_response instead of loader to preserve Kimi's Thinking process
                    file_output = self.interface.stream_rich_response(
                        api.chat_stream(
                            prompt=build_prompt,
                            context=self.state.loaded_files,
                            model=target_model,
                            provider=target_provider,
                        )
                    )

                    # [FIX] Auto-Continue Logic for Truncated Files in Build Loop
                    max_continues = 2
                    continue_count = 0
                    while (
                        file_output
                        and not file_output.strip().endswith("```")
                        and continue_count < max_continues
                    ):
                        continue_count += 1
                        self._step_warn(f"File {fpath} truncated. Resuming...")

                        tail_snippet = file_output[-1500:]
                        continue_prompt = (
                            f"SYSTEM_OVERRIDE: Your previous response generating code for `{fpath}` hit the token limit and was truncated mid-code block.\n"
                            "Here is the very end of your truncated output:\n"
                            f"...\n{tail_snippet}\n\n"
                            f"TASK: Continue generating the code for `{fpath}` EXACTLY from the next character. "
                            "Do NOT write any preamble, do NOT repeat the snippet above, and do NOT use markdown code fences unless closing the block.\n"
                            "CRITICAL: When you finish the code, YOU MUST output the closing triple backticks (```)."
                        )
                        
                        continuation = self.interface.stream_rich_response(
                            api.chat_stream(
                                prompt=continue_prompt,
                                context=self.state.loaded_files,
                                model=target_model,
                                provider=target_provider,
                            ),
                            show_reasoning=False,
                        )
                        if continuation:
                            file_output += "\n" + continuation
                        else:
                            break

                    if not file_output:
                        if attempt < max_retries - 1:
                            continue
                        else:
                            self._step_fail(
                                f"Fatal: Could not build {fpath} (empty response)."
                            )
                            return

                    # Process the output inside the attempt loop
                    modified = handle_ai_commands(file_output)

                    if modified:
                        # [INTEGRATED INSPECTION GATE]
                        # Verify the file on disk before allowing the loop to proceed
                        from nova_cli.local.utils import check_syntax

                        is_valid, err = (
                            check_syntax(_read_text(fpath), fpath)
                            if os.path.isfile(fpath)
                            else (False, "File not found on disk or is a directory.")
                        )

                        if is_valid:
                            final_built_files.append(fpath)
                            self._step_ok(f"Successfully integrated {fpath}.")

                            # Remove from state ONLY after successful sync and validation
                            if fpath in self.state.pending_build_files:
                                self.state.pending_build_files.remove(fpath)
                                self.state.save_build_state()
                            success = True
                            break  # Success! Exit the retry loop for this file
                        else:
                            last_syntax_error = err
                            self._step_fail(f"Inspection Failed for {fpath}: {err}")
                            if attempt < max_retries - 1:
                                self._step_warn(
                                    f"Triggering automatic rewrite (Attempt {attempt+2}/{max_retries})..."
                                )
                                time.sleep(1)
                                continue
                            else:
                                self._step_fail(
                                    f"Fatal: Could not build {fpath} after multiple attempts."
                                )
                                return
                    else:
                        self._step_warn(
                            f"Warning: {fpath} parser error (tag mismatch)."
                        )
                        if attempt < max_retries - 1:
                            time.sleep(1)
                            continue
                        else:
                            self._step_fail(
                                f"Fatal: Could not build {fpath} after multiple attempts due to parser errors."
                            )
                            return

                except RuntimeError as e:
                    if (
                        any(
                            err in str(e)
                            for err in [
                                "429",
                                "timeout",
                                "504",
                                "internet",
                                "connection",
                            ]
                        )
                        and attempt < max_retries - 1
                    ):
                        # Exponential backoff: 5s, 10s, 20s, 40s
                        wait_time = 5 * (2**attempt)
                        self.interface.print(
                            f"[yellow]>> Connection unstable. Retrying in {wait_time}s... ({attempt+1}/{max_retries})[/yellow]"
                        )
                        time.sleep(wait_time)
                        continue

                    # Final failure after retries
                    self.interface.display_error(
                        "Build Interrupted: Check your internet connection. \nType [bold cyan]continue[/bold cyan] to resume building from this file.",
                        title="Connection Failure",
                    )
                    return

            if not success:
                return

        if not self.state.pending_build_files:
            self._step_ok("Build complete. All modules synchronized.")

            # --- POST-BUILD ASSET SCANNER ---
            self.interface.print("\n[bold cyan]>> Scanning generated code for image assets...[/bold cyan]")
            found_assets = {}
            
            # 0. Extract Domain Tag, Theme & Init Cache from PLAN.md markdown content
            domain_tag = "business"
            theme = "dark"
            _plan_md = self.state.current_plan_content or ""

            # Extract explicit domain tag from markdown patterns
            _domain_patterns = [
                r'domain\s*tag[:\s]+([a-z0-9\-]+)',
                r'industry[:\s]+([a-z0-9\-]+)',
                r'business\s+type[:\s]+([a-z0-9\-]+)',
                r'niche[:\s]+([a-z0-9\-]+)',
                r'sector[:\s]+([a-z0-9\-]+)',
            ]
            for _dp in _domain_patterns:
                _dm = re.search(_dp, _plan_md, re.IGNORECASE)
                if _dm:
                    domain_tag = _dm.group(1).strip().lower()
                    break

            # If still generic, infer domain from business keywords in the plan body
            if domain_tag == "business":
                _infer_map = {
                    "audio": ["headphone", "speaker", "music", "audio", "sound", "studio", "amplifier", "earphone", "vinyl", "podcast"],
                    "food": ["restaurant", "bakery", "food", "cafe", "coffee", "cuisine", "meal", "dining", "catering", "roastery", "chef", "recipe"],
                    "fashion": ["clothing", "fashion", "apparel", "wear", "style", "boutique", "garment", "accessories", "streetwear", "luxury brand"],
                    "technology": ["software", "saas", "tech", "app", "platform", "digital", "developer", "api", "cloud", "ai", "startup", "product"],
                    "fitness": ["gym", "fitness", "workout", "health", "wellness", "sport", "training", "yoga", "nutrition", "athlete", "gear", "performance"],
                    "real-estate": ["property", "real estate", "housing", "architecture", "interior", "home", "apartment", "estate", "mansion", "realty"],
                    "finance": ["finance", "investment", "banking", "crypto", "fintech", "trading", "insurance", "wealth", "fund"],
                    "photography": ["photography", "photo", "camera", "portrait", "visual", "studio shoot", "photographer"],
                    "education": ["education", "course", "learning", "school", "university", "tutor", "e-learning", "bootcamp", "academy"],
                    "travel": ["travel", "hotel", "tourism", "destination", "flight", "booking", "resort", "adventure", "hospitality"],
                    "beauty": ["skincare", "beauty", "cosmetic", "serum", "moisturiser", "moisturizer", "cleanser", "toner", "skincare routine", "botanical", "organic beauty", "skin", "facial", "glow", "cream"],
                    "nature": ["forest", "botanical", "plant", "garden", "floral", "nature", "organic", "herbal", "zen", "japanese", "mist", "wilderness"],
                    "wellness": ["spa", "meditation", "mindfulness", "ritual", "holistic", "aromatherapy", "massage", "retreat", "self-care"],
                    "interior": ["furniture", "decor", "interior design", "home goods", "living space", "minimalist home", "lifestyle"],
                    "automotive": ["car", "vehicle", "automotive", "motorcycle", "supercar", "electric vehicle", "ev", "driving"],
                    "art": ["art", "gallery", "creative studio", "design studio", "illustration", "artist", "painting", "sculpture"],
                    "pet": ["pet", "dog", "cat", "animal", "veterinary", "grooming", "pet care", "kennel"],
                    "legal": ["law", "legal", "attorney", "lawyer", "firm", "counsel", "compliance", "justice"],
                    "medical": ["clinic", "medical", "healthcare", "hospital", "therapy", "dental", "doctor", "patient"],
                }
                _plan_lower = _plan_md.lower()
                for _tag, _keywords in _infer_map.items():
                    if any(_kw in _plan_lower for _kw in _keywords):
                        domain_tag = _tag
                        break

            # Extract theme from markdown — look for light/dark preference
            _theme_match = re.search(
                r'(?:theme|aesthetic|mode|background)[:\s]+([^\n]{3,50})',
                _plan_md, re.IGNORECASE
            )
            if _theme_match:
                _theme_val = _theme_match.group(1).strip().lower()
                theme = "light" if "light" in _theme_val else "dark"
                    
            cache_file = os.path.join(config.PROJECT_ROOT, ".nova", "image_cache.json")
            image_cache = {}
            if os.path.exists(cache_file):
                try:
                    with open(cache_file, "r") as cf: image_cache = json.load(cf)
                except: pass

            # 1. Scan the hard drive directly to guarantee we catch all freshly built files
            for root_dir, _, files in os.walk(config.PROJECT_ROOT):
                if "node_modules" in root_dir or ".git" in root_dir or ".nova" in root_dir:
                    continue
                for file in files:
                    if file.endswith(('.tsx', '.jsx', '.ts', '.js', '.html', '.css')):
                        filepath = os.path.join(root_dir, file)
                        try:
                            with open(filepath, 'r', encoding='utf-8') as f:
                                content = f.read()
                                    
                                # A. Find img & video tags — handles JSX expressions, template literals, no-extension paths
                                tag_pattern = re.compile(
                                    r'<(?:img|Image|video|source)\s+[^>]+?(?:/>|>)',
                                    re.IGNORECASE | re.DOTALL
                                )
                                src_pattern = re.compile(
                                    r'src=\s*(?:'
                                    r'\{[`\'"]([^`\'"{}]+)[`\'"]\}'   # JSX: src={`/assets/x`} or src={'x'}
                                    r'|["\']([^"\']+)["\']'            # Plain string: src="/assets/x"
                                    r')',
                                    re.IGNORECASE
                                )
                                alt_pattern = re.compile(
                                    r'alt=\s*(?:\{?["\'`])([^"\'`>]{5,})(?:["\'`]?\}?)',
                                    re.IGNORECASE
                                )
                                type_pattern = re.compile(
                                    r'data-image-type=\s*(?:\{?["\'`])([^"\'`>]+)(?:["\'`]?\}?)',
                                    re.IGNORECASE
                                )

                                for tag_str in tag_pattern.findall(content):
                                    src_match = src_pattern.search(tag_str)
                                    if not src_match:
                                        continue
                                    raw_path = (src_match.group(1) or src_match.group(2) or "").strip()
                                    # Only process paths pointing to an assets directory
                                    if not raw_path or "asset" not in raw_path.lower():
                                        continue
                                    asset_name = os.path.basename(raw_path.split("?")[0])
                                    if not asset_name:
                                        continue
                                    # Add .jpg extension if the filename has no image extension
                                    _has_ext = any(asset_name.lower().endswith(e) for e in ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.webm'])
                                    if not _has_ext:
                                        asset_name = asset_name + ".jpg"
                                    if asset_name in (".jpg", "jpg"):
                                        continue
                                    alt_match = alt_pattern.search(tag_str)
                                    type_match = type_pattern.search(tag_str)
                                    alt_text = alt_match.group(1).strip() if alt_match else ""
                                    img_type = type_match.group(1).strip() if type_match else "card"
                                    if asset_name not in found_assets or len(alt_text) > len(found_assets.get(asset_name, {}).get("alt", "")):
                                        found_assets[asset_name] = {"alt": alt_text, "type": img_type}

                                # B. Catch-all: any /assets/ string anywhere in the file (covers imports + dynamic refs)
                                raw_pattern = re.compile(
                                    r'["\`\']/(?:public/)?assets/([\w\-_]+(?:\.(?:jpg|jpeg|png|webp|mp4|webm))?)',
                                    re.IGNORECASE
                                )
                                for raw_match in raw_pattern.findall(content):
                                    asset_name = raw_match
                                    if not any(asset_name.lower().endswith(e) for e in ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.webm']):
                                        asset_name = asset_name + ".jpg"
                                    if asset_name not in found_assets:
                                        found_assets[asset_name] = {"alt": "", "type": "card"}
                        except Exception:
                            pass
                                
            if found_assets:
                # Determine asset directory (Vite uses public/assets, legacy uses assets/)
                assets_dir = os.path.join(config.PROJECT_ROOT, "public", "assets")
                if not os.path.exists(os.path.join(config.PROJECT_ROOT, "public")):
                    assets_dir = os.path.join(config.PROJECT_ROOT, "assets")
                        
                os.makedirs(assets_dir, exist_ok=True)
                self.interface.print(f"[dim]>> Found {len(found_assets)} image slots to populate.[/dim]")
                    
                try:
                    import urllib.request
                    img_api = BridgeyeAPIClient()
                        
                    for asset_name, asset_data in found_assets.items():
                        full_asset_path = os.path.join(assets_dir, asset_name)
                        alt_text = asset_data["alt"]
                        img_type = asset_data["type"]
                            
                        clean_name = os.path.splitext(asset_name)[0].replace("-", " ").replace("_", " ")
                        cache_key = f"{domain_tag}_{clean_name}_{img_type}"
                            
                        # Deduplication & Caching
                        if os.path.exists(full_asset_path):
                            continue
                                
                        if cache_key in image_cache and os.path.exists(image_cache[cache_key].get("local_path", "")):
                            import shutil
                            try:
                                shutil.copy(image_cache[cache_key]["local_path"], full_asset_path)
                                self.interface.print(f"[dim]  Loaded {asset_name} from cache...[/dim]")
                                continue
                            except Exception:
                                pass

                        search_query = clean_name
                        prompt_text = alt_text if alt_text else f"high quality commercial photography of {clean_name}, photorealistic, professional product shot, 8k"
                            
                        try:
                            self.interface.print(f"[dim]  Fetching {asset_name}...[/dim]")
                            asset_format = "video" if asset_name.lower().endswith(('.mp4', '.webm')) else "image"
                            secure_url = img_api.search_asset(
                                search_query=search_query, 
                                prompt=prompt_text, 
                                orientation="landscape", # Handled dynamically in backend
                                width=1200, 
                                height=800,
                                domain_tag=domain_tag,
                                image_type=img_type,
                                alt_text=alt_text,
                                asset_format=asset_format,
                                theme=theme
                            )
                            if secure_url:
                                import requests as _dl_req
                                _is_vid = asset_name.lower().endswith(('.mp4', '.webm'))
                                _timeout = 180 if _is_vid else 30
                                _dl_headers = {
                                    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                                    'Accept': '*/*',
                                    'Accept-Encoding': 'identity',
                                }
                                _dl_resp = _dl_req.get(
                                    secure_url,
                                    headers=_dl_headers,
                                    stream=True,
                                    timeout=_timeout,
                                    allow_redirects=True
                                )
                                _dl_resp.raise_for_status()

                                _downloaded_bytes = 0
                                with open(full_asset_path, 'wb') as _out_f:
                                    for _chunk in _dl_resp.iter_content(chunk_size=65536):
                                        if _chunk:
                                            _out_f.write(_chunk)
                                            _downloaded_bytes += len(_chunk)

                                # Verify file is not empty or truncated
                                _min_bytes = 50_000 if _is_vid else 1_000
                                if _downloaded_bytes < _min_bytes:
                                    self.interface.print(f"[yellow]  ⚠ {asset_name} download incomplete ({_downloaded_bytes}B) — removing[/yellow]")
                                    if os.path.exists(full_asset_path):
                                        os.remove(full_asset_path)
                                else:
                                    _size_str = f"{_downloaded_bytes/1024/1024:.1f}MB" if _is_vid else f"{_downloaded_bytes/1024:.0f}KB"
                                    self.interface.print(f"[green]  ✓ {asset_name} saved ({_size_str})[/green]")
                                    image_cache[cache_key] = {"local_path": full_asset_path, "url": secure_url}
                                    os.makedirs(os.path.dirname(cache_file), exist_ok=True)
                                    with open(cache_file, "w") as cf: json.dump(image_cache, cf)

                        except Exception as e:
                            self.interface.print(f"[yellow]  ⚠ Failed to fetch {asset_name}: {e}[/yellow]")
                            if os.path.exists(full_asset_path):
                                try:
                                    os.remove(full_asset_path)
                                except Exception:
                                    pass
                except Exception as e:
                            self.interface.print(f"[yellow]  Notice: Asset generation failed ({e})[/yellow]")

        # --- POST-BUILD ANIMATION & TOKEN VALIDATOR ---
        if not self.state.pending_build_files and is_web_plan:
            self.interface.print("\n[bold cyan]>> Validating Animation & Design Token System...[/bold cyan]")
            _src_dir = os.path.join(config.PROJECT_ROOT, "src")
            
            # Extract tokens
            _palette_colors = set()
            _tokens_path = os.path.join(_src_dir, "tokens.ts")
            if os.path.exists(_tokens_path):
                try:
                    with open(_tokens_path, "r", encoding="utf-8") as _f:
                        _tok_content = _f.read()
                        _found_hex = re.findall(r'#[0-9a-fA-F]{3,6}\b', _tok_content)
                        _palette_colors = {h.lower() for h in _found_hex}
                except Exception:
                    pass
            
            _violations = []
            _section_kws = ['hero', 'feature', 'testimonial', 'pricing', 'about', 'contact', 'faq', 'cta', 'service', 'gallery', 'team', 'footer', 'banner', 'stats']
            
            if os.path.exists(_src_dir):
                for _root_dir, _, _files in os.walk(_src_dir):
                    for _file in _files:
                        if _file.endswith((".tsx", ".jsx")):
                            _fpath = os.path.join(_root_dir, _file)
                            _fpath_norm = _fpath.replace("\\", "/")
                            
                            # CRITICAL: Exclude injected registry components from validation
                            if "components/ui" in _fpath_norm or "components/premium" in _fpath_norm or "components/core" in _fpath_norm:
                                continue
                                
                            try:
                                with open(_fpath, "r", encoding="utf-8") as _f:
                                    _content = _f.read()
                                
                                _file_violations = []
                                _fname_lower = _file.lower()
                                
                                # 1. Inline animations or missing GSAP configurations
                                if re.search(r'(initial|animate|exit)=\{\s*\{', _content) and "gsap" not in _content.lower():
                                    _file_violations.append("Contains unoptimized animation setups. Use centralized named motion variables or useGSAP definitions.")
                                
                                # 2. Section components motion verification
                                if any(kw in _fname_lower for kw in _section_kws):
                                    if "scrolltrigger" not in _content.lower() and "useinview" not in _content:
                                        _file_violations.append("Missing intersection checking or ScrollTrigger hooks for viewport entrance triggers.")
                                    if not re.search(r'from\s+[\'"].*animations[\'"]', _content) and "@gsap/react" not in _content:
                                        _file_violations.append("Missing shared design system configuration or GSAP hooks.")
                                
                                # 3. App.tsx
                                if _fname_lower in ["app.tsx", "app.jsx"]:
                                    if "AnimatePresence" not in _content:
                                        _file_violations.append("Missing AnimatePresence wrapper for page transitions.")
                                
                                # 4. Hardcoded tokens
                                if _palette_colors:
                                    _comp_hex = re.findall(r'#[0-9a-fA-F]{3,6}\b', _content)
                                    _hardcoded = [h for h in _comp_hex if h.lower() in _palette_colors]
                                    if _hardcoded:
                                        _file_violations.append(f"Hardcoded design token colors found: {', '.join(set(_hardcoded))}. Use Tailwind token classes instead.")
                                        
                                if _file_violations:
                                    _violations.append((_fpath, _file_violations))
                            except Exception:
                                pass
                            
            if _violations:
                self.interface.print(f"[yellow]>> Animation & Token System Violations Found: {len(_violations)}[/yellow]")
                for _v_path, _v_list in _violations:
                    self.interface.print(f"  [red]X[/red] {os.path.basename(_v_path)}: {'; '.join(_v_list)}")
                
                self.interface.print("[yellow]>> Triggering targeted correction pass...[/yellow]")
                
                _correction_prompt = "SYSTEM_OVERRIDE: POST-BUILD VALIDATION FAILED.\n"
                _correction_prompt += "The following files violated the animation or design token rules. You MUST output [EDIT] blocks to fix them.\n\n"
                
                _val_context = {}
                for _v_path, _v_list in _violations:
                    # FIX: Pass the exact relative path so the AI targets the right file and avoids "File not found"
                    _rel_path = os.path.relpath(_v_path, config.PROJECT_ROOT).replace("\\", "/")
                    _correction_prompt += f"FILE: {_rel_path}\nVIOLATIONS:\n" + "\n".join([f"- {v}" for v in _v_list]) + "\n\n"
                    try:
                        with open(_v_path, "r", encoding="utf-8") as _f:
                            _val_context[_rel_path] = _f.read()
                    except Exception: 
                        pass
                
                _correction_prompt += (
                    "TASK: Issue [EDIT] blocks to resolve all listed violations. Do not rewrite the entire file unless necessary. "
                    "CRITICAL: You MUST use the exact SEARCH/REPLACE format below:\n"
                    "[EDIT: path/to/file.ext]\n"
                    "<<<<<<< SEARCH\n"
                    "(exact original lines)\n"
                    "=======\n"
                    "(new corrected lines)\n"
                    ">>>>>>> REPLACE\n\n"
                    "Do not omit the <<<<<<< SEARCH or >>>>>>> REPLACE markers. "
                    "For inline animations, replace with named variants from animations.ts. For missing useInView, wrap the section correctly. For hardcoded colors, replace with the proper Tailwind class."
                )
                
                try:
                    _correction_output = self.interface.stream_rich_response(
                        api.chat_stream(
                            prompt=_correction_prompt,
                            context=_val_context,
                            model=target_model,
                            provider=target_provider,
                        )
                    )
                    if _correction_output:
                        handle_ai_commands(_correction_output, cwd=config.PROJECT_ROOT)
                        self.interface.print("[green]>> Animation and token violations corrected.[/green]")
                        new_ctx = run_contextify(config.PROJECT_ROOT, save_to_disk=True)
                        self.state.loaded_files.update(new_ctx)
                except Exception as e:
                    self.interface.print(f"[red]>> Failed to apply corrections: {e}[/red]")
            else:
                self.interface.print("[green]>> Animation & Token system validated. Zero violations.[/green]")

        # --- PRE-DELIVERY CHECKLIST (web builds only) ---
        if not self.state.pending_build_files and is_web_plan:
            self.interface.print("\n[bold cyan]>> Pre-Delivery Quality Checklist[/bold cyan]")
            for _label in [
                "No emoji icons — lucide-react SVGs only",
                "cursor-pointer on all clickable elements",
                "Hover states with smooth transitions (150-300ms)",
                "src/animations.ts imported — zero inline motion values",
                "src/tokens.ts imported — zero hardcoded hex colors in components",
                "AnimatePresence in App.tsx for page transitions",
                "useInView on all section components (scroll animations)",
                "Responsive at 375px / 768px / 1024px / 1440px",
                "Text contrast ≥ 4.5:1 (AA) in light mode",
                "All copy is business-specific — no Lorem ipsum",
            ]:
                self.interface.print(f"  [green]✔[/green] [dim]{_label}[/dim]")

        # [FIX]: Identify Project DNA before running
        import importlib.util

        base_path = os.path.dirname(
            os.path.dirname(
                os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
            )
        )
        dna_path = os.path.join(
            base_path, "skills", "frontend-web", "dna_scanner.py"
        )
        spec = importlib.util.spec_from_file_location("dna_scanner_post", dna_path)
        dna_mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(dna_mod)
        # Scan from PROJECT_ROOT to ensure Vite is detected even if terminal is in src
        dna = dna_mod.scan_project_dna(config.PROJECT_ROOT)

        # 1. Update Context Post-Build
        self._step_start("Contextifying project after build...")
        self.state.loaded_files.clear()
        self.state.loaded_paths.clear()

        project_context = run_contextify(config.PROJECT_ROOT, save_to_disk=True)
        self.state.loaded_files.update(project_context)
        for rel_path in project_context.keys():
            abs_p = os.path.abspath(rel_path).replace("\\", "/")
            self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
        self._step_ok("Context updated. project_context.txt regenerated.")

        # 2. Auto-Run What Nova Built (Intelligent Entry Point Identification)
        entry_point = None

        if final_built_files:
            try:
                # Use the Architect model to analyze the plan and the files we actually created
                with self.interface.create_loader(
                    "Identifying project entry point..."
                ):
                    id_prompt = (
                        "TASK: Identify the single primary entry point file (the one that starts the application) "
                        "from the list of files provided below, using the provided PLAN.md as reference.\n\n"
                        "RULES:\n"
                        "1. Output ONLY the filename (e.g., 'start.py' or 'src/main.js').\n"
                        "2. No explanation, no quotes, no markdown.\n"
                        "3. The file must exist in the provided list.\n\n"
                        f"FILES BUILT: {', '.join(final_built_files)}\n\n"
                        f"PLAN.md:\n{self.state.current_plan_content}"
                    )

                    ai_id = api.chat(
                        prompt=id_prompt,
                        context={},
                        model=self.model_name,
                        provider=self.provider,
                    )

                    candidate = ai_id.strip().replace('"', "").replace("'", "")

                    # Validate the AI returned a file we actually built
                    if candidate in final_built_files:
                        entry_point = candidate
                    else:
                        # Try a basename match as a safety fallback
                        for f in final_built_files:
                            if os.path.basename(f) == os.path.basename(candidate):
                                entry_point = f
                                break
            except Exception:
                entry_point = None

        # Fallback to hardcoded defaults if AI identification failed or was skipped
        if not entry_point:
            for candidate in [
                "main.py",
                "app.py",
                "index.js",
                "server.js",
                "run.py",
            ]:
                if candidate in self.state.loaded_files:
                    entry_point = candidate
                    break

        # Final Fallback to active file
        if not entry_point and self.state.active_file:
            entry_point = os.path.basename(self.state.active_file)

        if entry_point:
            self.interface.print(
                f"\n[bold green]>> Build finished. Identified Entry Point: {entry_point}[/bold green]"
            )

            # [FIX]: If Vite project, run the dev server from root instead of executing the file
            if dna.get("is_vite"):
                # Perform Global Environment Sync before running
                ensure_dependencies(
                    os.path.join(config.PROJECT_ROOT, "package.json")
                )

                self.interface.print(
                    "[bold cyan]>> Validating Vite Project Imports & Exports...[/bold cyan]"
                )
                import subprocess
                max_heal_attempts = 3
                for attempt in range(max_heal_attempts):
                    try:
                        # Fast, silent compilation check to catch broken imports/exports
                        npm_cmd = "npm.cmd" if os.name == "nt" else "npm"
                        process = subprocess.run([npm_cmd, "run", "build"], cwd=config.PROJECT_ROOT, capture_output=True, text=True)
                        if process.returncode != 0:
                            raise RuntimeError(process.stdout + "\n" + process.stderr)
                        self.interface.print("[bold green]>> Validation passed.[/bold green]")
                        break # Exit the loop on success
                    except Exception as e:
                        if attempt == max_heal_attempts - 1:
                            self.interface.print(f"[bold red]>> Validation Failed after {max_heal_attempts} attempts. Proceeding to Dev Server for manual inspection.[/bold red]")
                            break
                            
                        self.interface.print(f"[bold red]>> Validation Failed (Attempt {attempt+1}/{max_heal_attempts}).[/bold red]")
                        # Show the actual build errors to the user before healing (capped at 2000 chars to prevent terminal flood)
                        self.interface.script_output(str(e)[:2000], title="Compilation Errors", color="red")
                        self.interface.print("[bold yellow]>> Triggering Surgical Healing to fix imports, exports, and syntax errors...[/bold yellow]")
                                                    
                        rewrite_prompt = (
                            "SYSTEM_OVERRIDE: The Vite project build has FAILED due to severe import/export mismatches, syntax errors, or filename inconsistencies.\n"
                            f"BUILD ERROR LOG:\n{e}\n\n"
                            "TASK: You have the full project context. Analyze the error log and trace the exact files causing the failure.\n"
                            "RULES:\n"
                            "1. Fix any mismatched file extensions (.ts vs .tsx).\n"
                            "2. Ensure all components use strict relative imports (e.g., `../components/`), NEVER `@/`.\n"
                            "3. Ensure all exported names match the imported names and fix case-sensitivity.\n"
                            "4. If you see 'verbatimModuleSyntax' errors, you MUST use `import type` for types (e.g. `import type { ReactNode } from 'react';`).\n"
                            "5. CRITICAL: You MUST use the exact SEARCH/REPLACE format below:\n"
                            "[EDIT: path/to/file.ext]\n"
                            "<<<<<<< SEARCH\n"
                            "(exact original lines)\n"
                            "=======\n"
                            "(new corrected lines)\n"
                            ">>>>>>> REPLACE\n\n"
                            "Do not omit the <<<<<<< SEARCH or >>>>>>> REPLACE markers.\n"
                            "6. If a file is completely broken or missing, output `[CREATE: path/to/file.ext]` with the full code.\n"
                            "7. ONLY fix files directly mentioned or implicated in the error log. Do NOT rewrite the entire project.\n"
                            "8. IMAGE IMPORT ERRORS: If the error is 'Failed to resolve import' for a .jpg/.png/.svg, it means you tried to import an image that belongs in the `public` directory. Fix this by using an [EDIT] block to REMOVE the `import` statement, and replace the variable in the JSX with a hardcoded absolute string like `\"/assets/filename.jpg\"`.\n"
                            "9. READ-ONLY COMPONENTS: Premium registry components (like VHSHero.tsx, TerminalFooter.tsx) are LOCKED. Do NOT edit their raw code. If they have a missing import (like lucide-react or ../core/utils), use [CREATE: src/components/core/utils.ts] to provide the missing file, or fix how the parent page imports the component.\n"
                            "10. TS STRICT ERRORS: If you see TS2322 or TS2698 related to framer-motion variants, you MUST use an [EDIT] block to add `as any` to the transition object (e.g. `transition: { duration: 0.8 } as any`). If you see interface clashes (TS2430), add `// @ts-nocheck` to the top of the file.\n"
                            "11. RUNTIME ERRORS: If the error is 'Cannot read properties of undefined (reading 'map')', you MUST use an [EDIT] block to add a fallback to the array (e.g. `(items || []).map(...)` or `items?.map(...)`).\n"
                            "12. Do NOT provide explanations."
                        )
                                                    
                        # Prevent Token Truncation: Only send files implicated in the error log
                        error_log_str = str(e).replace("\\", "/").lower()
                        healing_context = {}
                        for filepath, content in self.state.loaded_files.items():
                            normalized_path = filepath.replace("\\", "/").lower()
                            filename = os.path.basename(normalized_path)
                            # Include if filename is in error log OR it's a core configuration/routing file
                            if filename in error_log_str or filename in ["main.tsx", "app.tsx", "package.json", "index.css"]:
                                healing_context[filepath] = content
                        if not healing_context:
                            healing_context = self.state.loaded_files
                                                    
                        rewrite_output = self.interface.stream_rich_response(
                            api.chat_stream(
                                prompt=rewrite_prompt,
                                context=healing_context,
                                model=target_model,
                                provider=target_provider,
                            )
                        )
                                                    
                        if rewrite_output:
                            modified = handle_ai_commands(rewrite_output, cwd=config.PROJECT_ROOT)
                            if modified:
                                self.interface.print("[green]>> Surgical healing applied.[/green]")
                            else:
                                self.interface.print("[yellow]>> AI provided a patch but SEARCH block did not match. Retrying...[/yellow]")
                            
                            # Refresh context to ensure sync
                            new_ctx = run_contextify(config.PROJECT_ROOT, save_to_disk=True)
                            self.state.loaded_files.update(new_ctx)
                        else:
                            self.interface.print("[red]>> Surgical healing failed to generate output. Retrying...[/red]")

                self.interface.print(
                    "[bold cyan]>> Launching Dev Server...[/bold cyan]"
                )
                self.cmd_run("npm run dev")
                                            
                import webbrowser
                self.interface.print("[green]>> Opening http://localhost:5173 in browser...[/green]")
                webbrowser.open("http://localhost:5173")
            else:
                self.cmd_run(entry_point)
        else:
            if dna.get("is_vite"):
                self.interface.print(
                    "\n[bold green]>> Build finished.[/bold green]"
                )
                ensure_dependencies(os.path.join(config.PROJECT_ROOT, "package.json"))
                self.interface.print("[bold cyan]>> Validating Vite Project Imports & Exports...[/bold cyan]")
                import subprocess
                max_heal_attempts = 3
                for attempt in range(max_heal_attempts):
                    try:
                        # Fast, silent compilation check to catch broken imports/exports
                        npm_cmd = "npm.cmd" if os.name == "nt" else "npm"
                        process = subprocess.run([npm_cmd, "run", "build"], cwd=config.PROJECT_ROOT, capture_output=True, text=True)
                        if process.returncode != 0:
                            raise RuntimeError(process.stdout + "\n" + process.stderr)
                        self.interface.print("[bold green]>> Validation passed.[/bold green]")
                        break # Exit the loop on success
                    except Exception as e:
                        if attempt == max_heal_attempts - 1:
                            self.interface.print(f"[bold red]>> Validation Failed after {max_heal_attempts} attempts. Proceeding to Dev Server for manual inspection.[/bold red]")
                            break
                            
                        self.interface.print(f"[bold red]>> Validation Failed (Attempt {attempt+1}/{max_heal_attempts}).[/bold red]")
                        # Show the actual build errors to the user before healing (capped at 2000 chars to prevent terminal flood)
                        self.interface.script_output(str(e)[:2000], title="Compilation Errors", color="red")
                        self.interface.print("[bold yellow]>> Triggering Surgical Healing to fix imports, exports, and syntax errors...[/bold yellow]")
                                                    
                        rewrite_prompt = (
                            "SYSTEM_OVERRIDE: The Vite project build has FAILED due to severe import/export mismatches, syntax errors, or filename inconsistencies.\n"
                            f"BUILD ERROR LOG:\n{e}\n\n"
                            "TASK: You have the full project context. Analyze the error log and trace the exact files causing the failure.\n"
                            "RULES:\n"
                            "1. Fix any mismatched file extensions (.ts vs .tsx).\n"
                            "2. Ensure all components use strict relative imports (e.g., `../components/`), NEVER `@/`.\n"
                            "3. Ensure all exported names match the imported names and fix case-sensitivity.\n"
                            "4. If you see 'verbatimModuleSyntax' errors, you MUST use `import type` for types (e.g. `import type { ReactNode } from 'react';`).\n"
                            "5. CRITICAL: You MUST use the exact SEARCH/REPLACE format below:\n"
                            "[EDIT: path/to/file.ext]\n"
                            "<<<<<<< SEARCH\n"
                            "(exact original lines)\n"
                            "=======\n"
                            "(new corrected lines)\n"
                            ">>>>>>> REPLACE\n\n"
                            "Do not omit the <<<<<<< SEARCH or >>>>>>> REPLACE markers.\n"
                            "6. If a file is completely broken or missing, output `[CREATE: path/to/file.ext]` with the full code.\n"
                            "7. ONLY fix files directly mentioned or implicated in the error log. Do NOT rewrite the entire project.\n"
                            "8. IMAGE IMPORT ERRORS: If the error is 'Failed to resolve import' for a .jpg/.png/.svg, it means you tried to import an image that belongs in the `public` directory. Fix this by using an [EDIT] block to REMOVE the `import` statement, and replace the variable in the JSX with a hardcoded absolute string like `\"/assets/filename.jpg\"`.\n"
                            "9. READ-ONLY COMPONENTS: Premium registry components (like VHSHero.tsx, TerminalFooter.tsx) are LOCKED. Do NOT edit their raw code. If they have a missing import (like lucide-react or ../core/utils), use [CREATE: src/components/core/utils.ts] to provide the missing file, or fix how the parent page imports the component.\n"
                            "10. TS STRICT ERRORS: If you see TS2322 or TS2698 related to framer-motion variants, you MUST use an [EDIT] block to add `as any` to the transition object (e.g. `transition: { duration: 0.8 } as any`). If you see interface clashes (TS2430), add `// @ts-nocheck` to the top of the file.\n"
                            "11. RUNTIME ERRORS: If the error is 'Cannot read properties of undefined (reading 'map')', you MUST use an [EDIT] block to add a fallback to the array (e.g. `(items || []).map(...)` or `items?.map(...)`).\n"
                            "12. Do NOT provide explanations."
                        )
                                                    
                        # Prevent Token Truncation: Only send files implicated in the error log
                        error_log_str = str(e).replace("\\", "/").lower()
                        healing_context = {}
                        for filepath, content in self.state.loaded_files.items():
                            normalized_path = filepath.replace("\\", "/").lower()
                            filename = os.path.basename(normalized_path)
                            # Include if filename is in error log OR it's a core configuration/routing file
                            if filename in error_log_str or filename in ["main.tsx", "app.tsx", "package.json", "index.css"]:
                                healing_context[filepath] = content
                        if not healing_context:
                            healing_context = self.state.loaded_files
                                                    
                        rewrite_output = self.interface.stream_rich_response(
                            api.chat_stream(
                                prompt=rewrite_prompt,
                                context=healing_context,
                                model=target_model,
                                provider=target_provider,
                            )
                        )
                                                    
                        if rewrite_output:
                            modified = handle_ai_commands(rewrite_output, cwd=config.PROJECT_ROOT)
                            if modified:
                                self.interface.print("[green]>> Surgical healing applied.[/green]")
                            else:
                                self.interface.print("[yellow]>> AI provided a patch but SEARCH block did not match. Retrying...[/yellow]")
                            
                            # Refresh context to ensure sync
                            new_ctx = run_contextify(config.PROJECT_ROOT, save_to_disk=True)
                            self.state.loaded_files.update(new_ctx)
                        else:
                            self.interface.print("[red]>> Surgical healing failed to generate output. Retrying...[/red]")

                self.interface.print("[bold cyan]>> Launching Dev Server...[/bold cyan]")
                self.cmd_run("npm run dev")
                            
                import webbrowser
                self.interface.print("[green]>> Opening http://localhost:5173 in browser...[/green]")
                webbrowser.open("http://localhost:5173")
            else:
                self.interface.print(
                    "\n[bold green]>> Build finished. Use 'run <filename>' or 'run <command>' to execute.[/bold green]"
                    )

    # _route_skill has been removed. Skill routing is now handled natively via the backend API payload.

    def _execute_skill_flow(
        self, skill_name: str, prompt_text: str, override_model: str = None
    ):
        """Executes the strict skill flow using the localized component data."""
        import questionary

        # Delegate to dedicated orchestrator for data-science
        if skill_name == "data-science":
            import importlib.util

            project_root = os.path.dirname(
                os.path.dirname(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                )
            )
            ds_path = os.path.join(
                project_root, "skills", "data-science", "scientist.py"
            )
            if not os.path.exists(ds_path):
                ds_path = os.path.join(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                    "skills",
                    "data-science",
                    "scientist.py",
                )

            if os.path.exists(ds_path):
                spec = importlib.util.spec_from_file_location("scientist", ds_path)
                scientist = importlib.util.module_from_spec(spec)
                spec.loader.exec_module(scientist)
                scientist.run(user_prompt=prompt_text)
            return

        # Delegate to dedicated orchestrator for frontend-web
        if skill_name == "frontend-web":
            import importlib.util

            base_path = os.path.dirname(
                os.path.dirname(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                )
            )
            dna_path = os.path.join(
                base_path, "skills", "frontend-web", "dna_scanner.py"
            )

            spec = importlib.util.spec_from_file_location("dna_scanner", dna_path)
            dna_mod = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(dna_mod)

            dna = dna_mod.scan_project_dna(os.getcwd())

            # --- BRANCHING LOGIC ---
            is_existing_vite = dna["type"] == "vite"
            build_choice = None

            if is_existing_vite:
                # SKIP scaffolding and questions
                self.interface.print(
                    f"[bold cyan]>> Vite Project DNA detected ({dna['framework'] or 'Modern JS'}). Bypassing setup...[/bold cyan]"
                )
            elif dna["type"] == "legacy":
                build_choice = questionary.select(
                    "Existing HTML project detected. How to proceed?",
                    choices=[
                        questionary.Choice(
                            title="Continue as Single File", value="single"
                        ),
                        questionary.Choice(
                            title="Upgrade to Vite Pipeline (Scaffold New)",
                            value="vite",
                        ),
                    ],
                ).ask()
            else:
                build_choice = questionary.select(
                    "How would you like to build this web project?",
                    choices=[
                        questionary.Choice(
                            title="Single File build (Less token heavy)", value="single"
                        ),
                        questionary.Choice(
                            title="Multiple Files build (Modern Vite Pipeline - Token heavy)",
                            value="vite",
                        ),
                    ],
                ).ask()

            if not is_existing_vite and not build_choice:
                return

            # --- TRIGGER SCAFFOLDER (Only if not already Vite) ---
            if build_choice == "vite":
                self.interface.print(
                    "[bold cyan]>> Launching Vite Scaffolding Pipeline...[/bold cyan]"
                )
                pipeline_path = os.path.join(
                    base_path, "skills", "frontend-web", "Vite_pipeline", "pipeline.py"
                )

                try:
                    spec = importlib.util.spec_from_file_location(
                        "vite_pipeline", pipeline_path
                    )
                    v_pipe = importlib.util.module_from_spec(spec)
                    spec.loader.exec_module(v_pipe)

                    target_path = v_pipe.run_vite_pipeline(self.interface)
                    if target_path:
                        self.cmd_cd(target_path)
                        # RE-SCAN DNA: Enable Vite coding rules for the very first prompt
                        dna = dna_mod.scan_project_dna(os.getcwd())
                        is_existing_vite = True
                    else:
                        return
                except Exception as e:
                    self.interface.print(f"[red]>> Pipeline error: {e}[/red]")
                    return

            # --- AI CONTEXT INJECTION (Step 3) ---
            if is_existing_vite:
                prompt_text = (
                    f"ENVIRONMENT: Existing Vite Project ({dna['framework'] or 'Modern JS'})\n"
                    "RULE: You MUST use component-based architecture. Create/Edit .jsx, .tsx, or CSS modules as needed.\n"
                    "RULE: NEVER output a single index.html unless specifically asked to edit the entry point.\n"
                    f"USER_REQUEST: {prompt_text}"
                )
            else:
                # Only show the banner for NEW/NON-VITE web builds
                from rich.panel import Panel

                self.interface.print()
                self.interface.print(
                    Panel(
                        "[bold white]Your NOVA Web Developer is here.[/bold white]\n[dim]Analyzing requirements and preparing your website blueprint...[/dim]",
                        title="[bold magenta]NOVA The Builder ACTIVE[/bold magenta]",
                        border_style="magenta",
                        expand=False,
                    )
                )

            import importlib.util

            project_root = os.path.dirname(
                os.path.dirname(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                )
            )
            builder_path = os.path.join(
                project_root, "skills", "frontend-web", "builder.py"
            )

            # Fallback path if installed as editable package
            if not os.path.exists(builder_path):
                builder_path = os.path.join(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                    "skills",
                    "frontend-web",
                    "builder.py",
                )

            if os.path.exists(builder_path):
                spec = importlib.util.spec_from_file_location(
                    "web_builder", builder_path
                )
                web_builder = importlib.util.module_from_spec(spec)
                sys.modules["web_builder"] = web_builder
                spec.loader.exec_module(web_builder)

                # Pre-build Context generation
                self._step_start("Preparing project context...")
                from nova_cli.local.contextifier import run_contextify

                run_contextify(os.getcwd(), save_to_disk=True)
                self._step_ok("Project context ready.")

                result = web_builder.run(
                    user_prompt=prompt_text, dna=dna, build_choice=build_choice
                )

                # Handle return signature (files, dna) from the builder skill
                modified_files = []
                updated_dna = dna
                if isinstance(result, tuple) and len(result) == 2:
                    modified_files, updated_dna = result
                else:
                    modified_files = result

                # VITE HANDOFF: "Handling the Silence"
                # If a Vite project was identified (either existing or just scaffolded),
                # we exit the skill logic and re-route to the core Architect.
                if updated_dna and updated_dna.get("is_vite"):
                    # Ensure terminal is at the project root for path alignment
                    if os.getcwd() != updated_dna.get("root_path"):
                        self.cmd_cd(updated_dna.get("root_path"))

                    config.PROJECT_ROOT = updated_dna.get("root_path")
                    self.interface.print(
                        f"[bold yellow]>> Anchor Set:[/bold yellow] Project Root: {config.PROJECT_ROOT}"
                    )

                    # Handoff instruction: Bridge the gap between scaffolding and planning
                    # [FIX]: Use SYSTEM_OVERRIDE to bypass skill heuristics and break the loop.
                    handoff_instruction = (
                        f"SYSTEM_OVERRIDE: A new Vite {updated_dna.get('framework', 'Modern JS')} project with {updated_dna.get('variant', 'js').upper()} has been scaffolded.\n"
                        f"USER_GOAL: {prompt_text}\n"
                        f"TASK: Act as Lead Architect. Create a modular, component-based PLAN.md using {updated_dna.get('framework', 'Modern JS')} and {updated_dna.get('variant', 'js').upper()} best practices."
                    )

                    self.interface.print(
                        "[cyan]>> Scaffolding complete. Rerouting to Architect for Modular Planning...[/cyan]"
                    )
                    # Programmatically re-trigger handle_ai_request with PLAN intent
                    self.handle_ai_request(handoff_instruction, nlp_intent="PLAN")
                    return

                # Contextify and Auto-Run (Mirroring standard build flow for Legacy/Non-Vite)
                if modified_files:
                    # --- POST-BUILD ASSET SCANNER (Legacy / Skill Flow) ---
                    self.interface.print("\n[bold cyan]>> Scanning generated code for image assets...[/bold cyan]")
                    found_assets = {}
                    
                    tag_pattern = re.compile(r'<(?:img|Image)\s+[^>]+>', re.IGNORECASE)
                    src_pattern = re.compile(r'src=["\']([^"\']*(?:assets/[^"\']+\.(?:jpg|jpeg|png|webp)))["\']', re.IGNORECASE)
                    alt_pattern = re.compile(r'alt=["\']([^"\']+)["\']', re.IGNORECASE)
                    raw_pattern = re.compile(r'(?:/?public)?/?(assets/[\w\-_\/]+\.(?:jpg|jpeg|png|webp))', re.IGNORECASE)

                    for content in self.state.loaded_files.values():
                        for tag_str in tag_pattern.findall(content):
                            src_match = src_pattern.search(tag_str)
                            if src_match:
                                raw_path = src_match.group(1)
                                clean_path = "assets/" + raw_path.split("assets/")[1]
                                alt_match = alt_pattern.search(tag_str)
                                alt_text = alt_match.group(1) if alt_match else ""
                                
                                if clean_path not in found_assets or len(alt_text) > len(found_assets.get(clean_path, "")):
                                    found_assets[clean_path] = alt_text
                                    
                        for raw_match in raw_pattern.findall(content):
                            if raw_match not in found_assets:
                                found_assets[raw_match] = ""
                                
                    if found_assets:
                        is_vite = dna.get("is_vite", False) if dna else False
                        base_asset_dir = "public/assets" if is_vite else "assets"
                        assets_dir = os.path.join(os.getcwd(), base_asset_dir)
                        os.makedirs(assets_dir, exist_ok=True)
                        self.interface.print(f"[dim]>> Found {len(found_assets)} image slots to populate.[/dim]")
                        
                        try:
                            img_api = BridgeyeAPIClient()
                            import urllib.request
                            for asset_path, alt_text in found_assets.items():
                                asset_name = os.path.basename(asset_path)
                                full_asset_path = os.path.join(assets_dir, asset_name)
                                os.makedirs(os.path.dirname(full_asset_path), exist_ok=True)
                                
                                if os.path.exists(full_asset_path): 
                                    continue
                                
                                clean_name = os.path.splitext(asset_name)[0].replace("-", " ").replace("_", " ")
                                
                                search_query = alt_text if alt_text else clean_name
                                prompt_text = alt_text if alt_text else f"high quality photography of {clean_name}, photorealistic, professional"
                                
                                orientation = "square" if any(x in clean_name.lower() for x in ["avatar", "profile", "headshot", "logo"]) else "landscape"
                                if "portrait" in prompt_text.lower() or "mobile" in prompt_text.lower():
                                    orientation = "portrait"
                                
                                try:
                                    self.interface.print(f"[dim]  Fetching {asset_name}...[/dim]")
                                    
                                    theme = "dark"
                                    try:
                                        if hasattr(self, 'state') and self.state.current_plan_content:
                                            import json
                                            theme = json.loads(self.state.current_plan_content).get("theme", "dark")
                                    except Exception:
                                        pass
                                        
                                    secure_url = img_api.search_asset(search_query, prompt_text, orientation, 1200, 800, theme=theme)
                                    if secure_url:
                                        import shutil
                                        img_req = urllib.request.Request(secure_url, headers={'User-Agent': 'Mozilla/5.0'})
                                        with urllib.request.urlopen(img_req) as response, open(full_asset_path, 'wb') as out_file:
                                            shutil.copyfileobj(response, out_file)
                                except Exception as e:
                                    self.interface.print(f"[yellow]  Warning: Failed to fetch {asset_name}: {e}[/yellow]")
                        except Exception as e:
                            self.interface.print(f"[yellow]  Notice: Asset generation failed ({e})[/yellow]")

                    self._step_start("Web build applied. Refreshing project context...")
                    from nova_cli.local.contextifier import run_contextify

                    new_context = run_contextify(os.getcwd(), save_to_disk=True)
                    self.state.loaded_files.update(new_context)
                    for rel_path in new_context.keys():
                        abs_p = os.path.abspath(rel_path).replace("\\", "/")
                        self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                    self._step_ok("Project context updated.")

                    # Determine entry point to run
                    entry_point = None
                    for f in modified_files:
                        if isinstance(f, str) and f.endswith("index.html"):
                            entry_point = f
                            break
                    if not entry_point and modified_files:
                        entry_point = modified_files[0]

                    if isinstance(entry_point, str) and not entry_point.startswith(
                        "DELETED:"
                    ):
                        self.interface.print(
                            f"\n[bold green]>> Automatically running {os.path.basename(entry_point)}...[/bold green]"
                        )
                        self.cmd_run(entry_point)

            else:
                self.interface.print(
                    "[red]>> Skill execution failed: builder.py not found in frontend-web.[/red]"
                )
            return

        import subprocess
        import questionary

        self.interface.print(f"[cyan]>> Initiating Skill Protocol: {skill_name}[/cyan]")

        # Resolve Absolute Path to Skills Folder
        project_root = os.path.dirname(
            os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        )
        skill_dir = os.path.join(project_root, "skills", skill_name)

        # Site-packages / Package-internal fallback
        if not os.path.exists(skill_dir):
            skill_dir = os.path.join(
                os.path.dirname(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                ),
                "skills",
                skill_name,
            )

        target_md_filename = "SKILL.md"
        analysis_mode = "simple"

        # NEW: Data Analysis Interactive Menu
        if skill_name == "data-analysis":
            choice_main = questionary.select(
                "How would you like to analyze this data?",
                choices=[
                    "1. Simple Analysis with interpretation",
                    "2. Technical Dashboard (EDA)",
                    "3. Strategic BI Dashboard (Executive View)",
                ],
            ).ask()

            if choice_main and "2. Technical Dashboard" in choice_main:
                choice_dash = questionary.select(
                    "Which dashboard technology should NOVA use?",
                    choices=["1. Streamlit", "2. Browser based (Html, css, js)"],
                ).ask()

                if choice_dash and "1. Streamlit" in choice_dash:
                    target_md_filename = "streamlit_da_dashaboard.md"
                    analysis_mode = "streamlit"
                elif choice_dash and "2. Browser based" in choice_dash:
                    target_md_filename = "dashboard.md"
                    analysis_mode = "browser"

            elif choice_main and "3. Strategic BI Dashboard" in choice_main:
                target_md_filename = "BA_skill.md"
                analysis_mode = "bi"

            # Determine dynamic subtext
            if analysis_mode == "streamlit":
                analyst_subtext = "Profiling dataset(s) and preparing your Streamlit executive dashboard..."
            elif analysis_mode == "browser":
                analyst_subtext = "Profiling dataset(s) and preparing your Browser-based executive dashboard..."
            elif analysis_mode == "bi":
                analyst_subtext = "Invoking Kimi BI Orchestrator: Analyzing data patterns and architecting strategic insights..."
            else:
                analyst_subtext = (
                    "Profiling dataset(s) and preparing your comprehensive analysis..."
                )

            from rich.panel import Panel

            self.interface.print()
            self.interface.print(
                Panel(
                    f"[bold white]Your NOVA Data Analyst is here.[/bold white]\n[dim]{analyst_subtext}[/dim]",
                    title="[bold cyan]NOVA Data Analyst ACTIVE[/bold cyan]",
                    border_style="cyan",
                    expand=False,
                )
            )

        skill_md_path = os.path.abspath(os.path.join(skill_dir, target_md_filename))
        pipeline_path = os.path.abspath(os.path.join(skill_dir, "pipeline.py"))

        if not os.path.exists(skill_md_path):
            self.interface.print(
                f"[red]>> Skill execution failed: {skill_md_path} not found.[/red]"
            )
            return

        self._step_start(f"Loading {skill_name} component context...")
        skill_context = {}
        with open(skill_md_path, "r", encoding="utf-8") as f:
            skill_context[target_md_filename] = f.read()

        # Execute pipeline.py to extract JSON data
        if os.path.exists(pipeline_path):
            self._step_start(f"Executing {skill_name} pipeline preprocessing...")

            # 1. Extract and Validate Target File(s)
            file_matches = re.findall(
                r"[\w\.\-/]+\.(?:csv|xlsx|json|parquet|xls)", prompt_text
            )
            target_files = []

            # Check if any matches exist physically on disk
            for match in file_matches:
                clean_match = match.strip("'\"")
                if os.path.exists(clean_match) and clean_match not in target_files:
                    target_files.append(clean_match)

            if not target_files:
                self._step_fail("Data Analysis Aborted.")
                msg = (
                    f"NOVA couldn't find the data file '{file_matches[0]}' in your current folder."
                    if file_matches
                    else "No supported data file (.csv, .xlsx, etc.) was detected in your request."
                )
                self.interface.display_error(msg, title="File Not Found")
                return

            # 2. Execute Data Pipeline for all files
            files_str = ", ".join(target_files)
            self._step_start(f"Profiling dataset(s): {files_str}")
            pipeline_args = [sys.executable, pipeline_path] + target_files

            try:
                result = subprocess.run(
                    pipeline_args, cwd=os.getcwd(), capture_output=True, text=True
                )
                if result.returncode == 0:
                    skill_context["pipeline_output.json"] = result.stdout
                    self._step_ok(f"Dataset(s) '{files_str}' profiled successfully.")
                else:
                    self._step_fail(f"Pipeline execution failed for '{files_str}'.")
                    self.interface.print(
                        f"[red]Error:[/red] {result.stderr or result.stdout}"
                    )
                    return  # Hard stop if profiling fails
            except Exception as e:
                self._step_fail(f"Critical error running pipeline: {e}")
                return

        # --- NULL QUALITY GATE ---
        # Fires for all analysis modes. Detects columns with >=80% null values,
        # shows them to the user, and gives an option to clean or continue.
        try:
            import json as _json

            _pipeline_json = _json.loads(
                skill_context.get("pipeline_output.json", "{}")
            )
            _high_null_cols = [
                col
                for col in _pipeline_json.get("columns", [])
                if col.get("null_pct", 0) >= 80
            ]
            if _high_null_cols:
                from rich.table import Table as _RichTable

                _null_table = _RichTable(
                    title="[bold yellow]⚠  High-Null Columns Detected (≥80% missing)[/bold yellow]",
                    border_style="yellow",
                    show_lines=True,
                )
                _null_table.add_column("Column", style="bold white", min_width=20)
                _null_table.add_column("Null %", style="bold red", justify="right")
                _null_table.add_column("Type", style="dim")
                for _col in _high_null_cols:
                    _null_table.add_row(
                        _col["name"],
                        f"{_col['null_pct']}%",
                        _col.get("col_type", "unknown"),
                    )
                self.interface.print()
                self.interface.print(_null_table)
                self.interface.print()

                _clean_choice = questionary.select(
                    f"{len(_high_null_cols)} column(s) carry ≥80% null values and will distort analysis. How would you like to proceed?",
                    choices=[
                        "Drop these columns and use a cleaned file",
                        "Continue with raw data (keep all columns)",
                    ],
                ).ask()

                if _clean_choice and "Drop" in _clean_choice:
                    self._step_start("Cleaning dataset: dropping high-null columns...")
                    import tempfile as _tempfile

                    _cols_to_drop = [c["name"] for c in _high_null_cols]
                    _src_file = target_files[0]
                    _cleaned_filename = f"cleaned_{os.path.splitext(os.path.basename(_src_file))[0]}.csv"

                    # Write a self-contained cleaning script to a temp file to avoid
                    # shell quoting issues with paths or column names containing spaces
                    _clean_lines = [
                        "import pandas as pd, os, sys",
                        f"src = {repr(_src_file)}",
                        f"cols_to_drop = {repr(_cols_to_drop)}",
                        "ext = os.path.splitext(src)[1].lower()",
                        "if ext == '.csv':",
                        "    df = pd.read_csv(src)",
                        "elif ext in ['.xlsx', '.xls']:",
                        "    df = pd.read_excel(src)",
                        "elif ext == '.json':",
                        "    df = pd.read_json(src)",
                        "elif ext == '.parquet':",
                        "    df = pd.read_parquet(src)",
                        "else:",
                        "    print(f'Unsupported extension: {ext}', file=sys.stderr); sys.exit(1)",
                        "cols_present = [c for c in cols_to_drop if c in df.columns]",
                        "df_clean = df.drop(columns=cols_present)",
                        f"out = {repr(_cleaned_filename)}",
                        "df_clean.to_csv(out, index=False)",
                        "print(f'Saved: {out} | Dropped {len(cols_present)} column(s): {cols_present} | New shape: {df_clean.shape}')",
                    ]

                    with _tempfile.NamedTemporaryFile(
                        mode="w",
                        suffix="_nova_clean.py",
                        delete=False,
                        dir=os.getcwd(),
                        encoding="utf-8",
                    ) as _tf:
                        _tf.write("\n".join(_clean_lines))
                        _temp_clean_path = _tf.name

                    try:
                        _clean_result = subprocess.run(
                            [sys.executable, _temp_clean_path],
                            cwd=os.getcwd(),
                            capture_output=True,
                            text=True,
                        )
                    finally:
                        try:
                            os.remove(_temp_clean_path)
                        except Exception:
                            pass

                    if _clean_result.returncode == 0:
                        self._step_ok(f"Cleaned file ready: {_cleaned_filename}")
                        self.interface.print(
                            f"[dim]{_clean_result.stdout.strip()}[/dim]"
                        )
                        # Redirect all downstream processing to the cleaned file
                        target_files = [_cleaned_filename]
                        files_str = _cleaned_filename
                        # Re-profile so domain inference and dashboard use clean stats
                        self._step_start("Re-profiling cleaned dataset...")
                        _re_result = subprocess.run(
                            [sys.executable, pipeline_path, _cleaned_filename],
                            cwd=os.getcwd(),
                            capture_output=True,
                            text=True,
                        )
                        if _re_result.returncode == 0:
                            skill_context["pipeline_output.json"] = _re_result.stdout
                            self._step_ok("Cleaned dataset profiled successfully.")
                        else:
                            self._step_warn(
                                "Re-profiling failed. Proceeding with original profile."
                            )
                    else:
                        self._step_warn(
                            f"Cleaning script failed: {_clean_result.stderr[:120].strip()}. "
                            "Proceeding with raw data."
                        )
        except Exception as _e:
            self._step_warn(
                f"Null quality gate encountered a non-fatal error: {_e}. Continuing."
            )

        self._step_ok(f"Context loaded for {skill_name}.")

        # --- KIMI STRATEGIC DOMAIN INFERENCE PASS ---
        if analysis_mode == "bi":
            self._step_start("Kimi is performing Strategic Domain Inference...")
            mapping_prompt = (
                "SYSTEM: You are a Principal Business Consultant. Analyze this dataset profile and raw data head to determine the Business Narrative.\n"
                "1. VERTICAL: Identify the industry (Sales, Logistics, FinTech, etc.).\n"
                "2. NORTH STAR: What is the primary metric of success found in this data?\n"
                "3. ENTITIES: Identify human/organizational entities for leaderboards (Agents, Managers, Teams).\n"
                "4. FLOW: Is this a funnel, a timeline, or a volume-based dataset?\n"
                "5. AUDIT: Identify 'Actionable Alerts' (Stagnant records, missing high-value info, etc.).\n"
                "Output EXACTLY a JSON object with keys: vertical, north_star, entities, flow_type, audit_alerts.\n\n"
                f"PROFILE_JSON: {skill_context.get('pipeline_output.json', '{}')}"
            )
            api_client = BridgeyeAPIClient()
            # Switching to Kimi for the Inference
            domain_map = api_client.chat(
                prompt=mapping_prompt,
                context={},
                model="moonshotai/kimi-k2.6",
                provider="openrouter",
            )
            skill_context["domain_mapping.json"] = domain_map
            self._step_ok("Kimi Intelligence initialized. Strategic narrative locked.")

        # --- COLUMN_MANIFEST BUILDER (BI Mode only) ---
        # Extracts a compact, token-efficient manifest from pipeline_output.json.
        # Replaces raw JSON in the prompt so Kimi sees actual categorical values,
        # actual date ranges, and actual numeric bounds — no guessing, any domain.
        column_manifest = ""
        if analysis_mode == "bi":
            try:
                import json as _jm

                _pj = _jm.loads(skill_context.get("pipeline_output.json", "{}"))
                _manifest_lines = ["COLUMN_MANIFEST (derived from pipeline profile):"]
                _manifest_lines.append(
                    f"Dataset: {_pj.get('shape', {}).get('rows', '?')} rows x "
                    f"{_pj.get('shape', {}).get('cols', '?')} cols"
                )
                _manifest_lines.append(f"Duplicates: {_pj.get('duplicate_pct', 0)}%")
                _manifest_lines.append("")
                for _col in _pj.get("columns", []):
                    _ctype = _col.get("col_type", "unknown")
                    _null = _col.get("null_pct", 0)
                    _name = _col.get("name", "?")

                    # [TASK 2] Do not use columns with null values in the BI dashboard
                    if _null > 0:
                        continue

                    _line = f"  [{_ctype}] {_name} | null={_null}%"
                    if _ctype == "categorical":
                        _tv = _col.get("top_values", {})
                        if _tv:
                            _vals = ", ".join(
                                f'"{k}"({v})' for k, v in list(_tv.items())[:7]
                            )
                            _line += f" | values: {_vals}"
                    elif _ctype == "numeric":
                        _line += (
                            f" | min={_col.get('min')} max={_col.get('max')} "
                            f"mean={_col.get('mean')} skew={_col.get('skewness')}"
                        )
                    elif _ctype == "datetime":
                        _line += (
                            f" | range: {_col.get('min_date')} → {_col.get('max_date')} "
                            f"({_col.get('range_days')} days)"
                        )
                    elif _ctype == "boolean":
                        _vc = _col.get("value_counts", {})
                        _line += f" | values: {_vc}"
                    _manifest_lines.append(_line)
                _manifest_lines.append("")
                _manifest_lines.append(
                    "High correlations: " + str(_pj.get("high_correlations", []))
                )
                _manifest_lines.append(
                    "Quality flags: " + "; ".join(_pj.get("flags", []))
                )
                column_manifest = "\n".join(_manifest_lines)
            except Exception as _em:
                column_manifest = f"[COLUMN_MANIFEST unavailable: {_em}]"

        # --- CUSTOM DASHBOARD REQUIREMENTS GATE (BI Mode only) ---
        # Captures any specific KPIs / charts the user wants before generation starts.
        # Empty input (Enter) means fully auto-generated.
        user_dashboard_requirements = ""
        if analysis_mode == "bi":
            self.interface.print()
            _custom_req = questionary.text(
                "Any specific KPIs, charts, or metrics you want in the dashboard?\n"
                "  e.g. 'Monthly revenue trend, agent leaderboard, conversion funnel by region'\n"
                "  Press Enter to auto-generate based on domain inference:"
            ).ask()
            if _custom_req and _custom_req.strip():
                user_dashboard_requirements = _custom_req.strip()
                self.interface.print(
                    f"[dim]>> Custom requirements locked in: {user_dashboard_requirements}[/dim]"
                )
            else:
                self.interface.print(
                    "[dim]>> No custom requirements. Auto-generating optimal CXO layout...[/dim]"
                )
            self.interface.print()

        # Enforce Kimi k2.6 for Skill Execution
        target_model = "moonshotai/kimi-k2.6"
        target_provider = "openrouter"

        # Display the Purple Upgrade Banner
        self.interface.display_coding_mode(target_model)

        # Build optional custom requirements block for BI mode
        _custom_req_block = (
            (
                f"\n\nUSER_DASHBOARD_REQUIREMENTS — HIGHEST PRIORITY. You MUST implement these "
                f"exactly as specified in addition to all standard dashboard sections:\n{user_dashboard_requirements}"
            )
            if user_dashboard_requirements
            else ""
        )

        # Build data loading context block for BI mode
        _data_loading_block = ""
        if analysis_mode == "bi":
            _file_exts = list({os.path.splitext(f)[1].lower() for f in target_files})
            _multi_file_note = (
                (
                    f"The user has {len(target_files)} data files with the same schema: "
                    f"{', '.join(os.path.basename(f) for f in target_files)}. "
                    "The upload zone MUST accept all of them simultaneously and concat the results."
                )
                if len(target_files) > 1
                else (
                    f"The user has 1 data file: {os.path.basename(target_files[0])} "
                    f"(extension: {_file_exts[0]}). The upload zone must accept this file type."
                )
            )
            _data_loading_block = (
                f"\n\n"
                f"=== CRITICAL ARCHITECTURE CONSTRAINT — READ FIRST, OVERRIDE EVERYTHING ELSE ===\n"
                f"THE DASHBOARD MUST SHOW AN UPLOAD SCREEN ON FIRST LOAD. NO EXCEPTIONS.\n\n"
                f"STEP 1 — On page load, show ONLY a full-screen drag-and-drop upload zone.\n"
                f"  - Do NOT render any charts, KPIs, tabs, or filters on first load.\n"
                f"  - Do NOT hardcode any data arrays or sample rows anywhere in the JavaScript.\n"
                f"  - The upload zone must accept .csv and .xlsx/.xls.\n"
                f"  - Use PapaParse CDN for CSV and SheetJS CDN for Excel.\n\n"
                f"STEP 2 — Only AFTER the user uploads a file: parse it into let DATA = []\n"
                f"  then call renderDashboard(DATA) to build all charts and KPIs.\n\n"
                f"THE COLUMN_MANIFEST AND domain_mapping.json BELOW ARE SCHEMA BLUEPRINTS ONLY.\n"
                f"They tell you WHICH columns exist and WHAT charts/KPIs to build — NOT the actual data.\n"
                f"NEVER write hardcoded series like: series: [123, 456] or labels: ['A','B'] from the manifest.\n"
                f"ALL chart series values must be computed from DATA at runtime via reduce/filter/groupBy.\n\n"
                f"{_multi_file_note}\n"
                f"=== END CRITICAL CONSTRAINT ==="
            )

        # Build column manifest block
        _manifest_block = (f"\n\n{column_manifest}") if column_manifest else ""

        skill_prompt = (
            f"SYSTEM_OVERRIDE: You are executing the '{skill_name}' skill.\n"
            f"Strictly follow the rules, intent triggers, and instructions defined in the provided {target_md_filename} context.\n"
            "Always use the exact [CREATE: filename.ext] syntax to output files.\n\n"
            f"USER_REQUEST: {prompt_text}"
            f"{_manifest_block}"
            f"{_data_loading_block}"
            f"{_custom_req_block}"
        )

        self.interface.print(
            f"[cyan]>> NOVA is processing the {skill_name} request...[/cyan]"
        )
        api = BridgeyeAPIClient()

        # Stream the reasoning and output
        output = self.interface.stream_rich_response(
            api.chat_stream(
                prompt=skill_prompt,
                context=skill_context,
                model=target_model,
                provider=target_provider,
                repo_map=prompts.get_repo_map_cached(os.getcwd()),
            )
        )

        # [FIX] Auto-Continue Logic for Truncated Dashboards
        max_continues = 3
        continue_count = 0
        while (
            output
            and not output.strip().endswith("```")
            and continue_count < max_continues
        ):
            continue_count += 1
            self._step_warn(
                f"Output truncated. Requesting continuation (Part {continue_count + 1})..."
            )

            # Feed the partial output back to Kimi to continue the sequence
            continue_prompt = (
                "CONTINUE_GENERATION: Your previous response was truncated mid-code. "
                "Please continue exactly from where you left off. Do not repeat the preamble or the start of the code. "
                "Start immediately with the next character of the file."
            )

            # Disable full reasoning display on continuations to save terminal resources
            continuation = self.interface.stream_rich_response(
                api.chat_stream(
                    prompt=continue_prompt,
                    context={
                        "previous_partial_output": output[-2000:]
                    },  # Send only tail context
                    model=target_model,
                    provider=target_provider,
                ),
                show_reasoning=False,
            )
            if continuation:
                output += "\n" + continuation
            else:
                break

        if not output:
            self._step_fail("No response received from Skill Execution.")
            return

        self.state.last_ai_response = output

        # 3. Parse [CREATE] tags, save file, and trigger execution
        modified_files = handle_ai_commands(output)

        if modified_files:
            prompts.clear_file_tree_cache()

            if analysis_mode == "streamlit":
                # Dashboard Execution Phase (Streamlit)
                for fpath in modified_files:
                    if (
                        isinstance(fpath, str)
                        and fpath.endswith(".py")
                        and not fpath.startswith("DELETED:")
                    ):
                        self.interface.print(
                            f"[bold green]>> Auto-running Streamlit Dashboard: {os.path.basename(fpath)}...[/bold green]"
                        )
                        self.cmd_run(fpath)
                        break
            elif analysis_mode in ["browser", "bi"]:
                # Dashboard Execution Phase (HTML/JS)
                for fpath in modified_files:
                    if (
                        isinstance(fpath, str)
                        and fpath.endswith((".html", ".htm"))
                        and not fpath.startswith("DELETED:")
                    ):
                        title = (
                            "Strategic BI Dashboard"
                            if analysis_mode == "bi"
                            else "Browser Dashboard"
                        )
                        self.interface.print(
                            f"[bold green]>> Auto-running {title}: {os.path.basename(fpath)}...[/bold green]"
                        )
                        self.cmd_run(fpath)
                        break
            else:
                # Simple Analysis Execution Phase
                for fpath in modified_files:
                    if (
                        isinstance(fpath, str)
                        and fpath.endswith(".py")
                        and not fpath.startswith("DELETED:")
                    ):
                        self.interface.print(
                            f"[bold green]>> Auto-running generated skill script: {os.path.basename(fpath)}...[/bold green]"
                        )

                        ensure_dependencies(fpath)

                        # Capture the output for interpretation
                        import subprocess
                        from nova_cli.local.healer.runner import run_with_healing

                        try:
                            # Use run_with_healing to ensure the file works, then capture final output
                            execution_result = run_with_healing(
                                command_args=[sys.executable, fpath],
                                cwd=os.getcwd(),
                                model=target_model,
                                provider=target_provider,
                                context=self.state.loaded_files,
                            )

                            # --- INTERPRETATION PHASE (Groq 120b) ---
                            if execution_result:
                                self.interface.print(
                                    "\n[bold cyan]• NOVA is interpreting the analysis results...[/bold cyan]"
                                )
                                interpret_prompt = (
                                    "SYSTEM_OVERRIDE: You are a Lead Data Scientist.\n"
                                    f"The following is the terminal output from a data analysis script run on {', '.join(target_files)}.\n"
                                    "Interpret the numbers, trends, and quality flags. Provide a high-level executive summary.\n\n"
                                    f"TERMINAL_OUTPUT:\n{execution_result}"
                                )

                                self.interface.stream_rich_response(
                                    api.chat_stream(
                                        prompt=interpret_prompt,
                                        context={},
                                        model="openai/gpt-oss-120b",
                                        provider="openrouter",
                                    )
                                )
                        except Exception as e:
                            self.interface.print(
                                f"[red]Execution interpretation failed: {e}[/red]"
                            )
                        break
        else:
            self._step_warn(
                "No files were created or modified by the AI. Check if [CREATE] tags were missing in the response."
            )


--- FILE: cli/shell_parts/handlers.py ---

import logging
import os
import re
import shlex
import subprocess
import sys
import time

from core import prompts
from nova_cli import config
from nova_cli.local.contextifier.engine import run_contextify
from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.file_manager.git_ops import git_status, manual_commit, perform_pull, perform_push, update_repo_path
from nova_cli.local.file_manager.io_ops import load_file, map_directory, save_code_to_file
from nova_cli.local.file_manager.path_ops import resolve_path
from nova_cli.local.healer.runner import run_with_healing
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli.nova_core.auth.client import NovaAuthClient
from nova_cli.nova_core.auth.storage import save_auth



class ShellHandlersMixin:
    def cmd_overdrive(self, args):
            # Retrieve the command name used to trigger this handler
            cmd_name = sys._getframe(1).f_locals.get('cmd_base', '').lower()
            
            # If the command contains any 'exit' or 'off' keywords, disable overdrive
            if any(kw in cmd_name for kw in ["exit", "free", "off"]):
                config.OVERDRIVE = False
            else:
                config.OVERDRIVE = not config.OVERDRIVE
                
            status = "[bold green]ENABLED[/bold green]" if config.OVERDRIVE else "[bold red]DISABLED[/bold red]"
            self.interface.print(f"[cyan]>> Overdrive Mode: {status}[/cyan]")

    def cmd_makeroot(self, args):
            config.PROJECT_ROOT = os.path.abspath(os.getcwd())
            self.interface.print(f"[bold yellow]>> Restricted Root Set:[/bold yellow] {config.PROJECT_ROOT}")
            self.interface.print("[dim]NOVA will no longer create or edit files outside this directory.[/dim]")

    def cmd_exitroot(self, args):
            config.PROJECT_ROOT = config.INITIAL_ROOT
            self.interface.print(f"[bold green]>> Root Restored to Initial Path:[/bold green] {config.PROJECT_ROOT}")

    def cmd_doctor(self, args):
            from nova_cli.cli.main import _doctor
            _doctor()

    def cmd_help(self, args):
            help_data = {
                "Build & Execute": {
                    "build / build it": "Execute the implementation steps in PLAN.md",
                    "continue": "Resume an interrupted build process",
                    "run <cmd>": "Run code with automated error healing",
                    "clean": "Trigger Janitor for style & formatting refactor"
                },
                "File & Context": {
                    "ls / :map": "View project blueprint (AST map)",
                    ":create [folder|file] <name>": "Create a folder or file instantly",
                    ":delete [folder|file] <name>": "Delete a folder or file safely",
                    ":load <path>": "Load file content into AI memory",
                    ":rename <old> <new>": "Rename a file or folder",
                    ":unload": "Clear specific or all files from context",
                    ":paste": "Provide multi-line logs or snippets",
                    "cd / pwd": "Navigate directories / Print current path"
                },
                "System & Auth": {
                    "login": "Authenticate your NOVA session",
                    "logout": "Clear local session and tokens",
                    ":model": "Switch AI reasoning/coding models",
                    ":gitoptions": "Git Automation (Commit/Push/Pull)",
                    ":overdrive": "Enable auto-confirm (Prompt turns RED)",
                    ":exit overdrive": "Disable auto-confirm mode",
                    ":makeroot": "Lock NOVA operations to current folder",
                    ":exitroot": "Release folder lock to initial root",
                    "reset / exit": "Clear session / Shutdown NOVA"
                }
            }
            self.interface.render_help_menu(help_data)

    def cmd_gitoptions(self, args):
            from nova_cli.local.file_manager.git_ops import initialize_repo
            
            while True:
                # Pass current state to UI for display
                choice = self.interface.show_git_options(config.GIT_AUTO_COMMIT, config.GIT_AUTO_PUSH)

                if choice == "Back":
                    break
                elif choice == "Initialize":
                    initialize_repo()
                elif choice == "Toggle Auto-Commit":
                    config.GIT_AUTO_COMMIT = not config.GIT_AUTO_COMMIT
                    status = "ENABLED" if config.GIT_AUTO_COMMIT else "DISABLED"
                    self.interface.print(f"[yellow]>> Auto-Commit: {status}[/yellow]")
                elif choice == "Toggle Auto-Push":
                    config.GIT_AUTO_PUSH = not config.GIT_AUTO_PUSH
                    status = "ENABLED" if config.GIT_AUTO_PUSH else "DISABLED"
                    self.interface.print(f"[yellow]>> Auto-Push: {status}[/yellow]")
                elif choice == "Manual Commit":
                    manual_commit(model=self.model_name, provider=self.provider)
                elif choice == "Push":
                    perform_push(model=self.model_name, provider=self.provider)
                elif choice == "Pull":
                    perform_pull()
                elif choice == "Force Sync":
                    from nova_cli.local.file_manager.git_ops import force_sync_with_origin
                    force_sync_with_origin()
                elif choice == "Git Status":
                    git_status()
                    self.interface.input("[dim]Press Enter to continue...[/dim]")

    def cmd_logout(self, args):
            from nova_cli.nova_core.auth.storage import logout
            logout()
            self.state.reset() # Clear all loaded context as well
            self.interface.print("[bold yellow]>> Logged out.[/bold yellow] Local session and tokens have been cleared.")

    def cmd_login(self, args):
            auth = NovaAuthClient()

            self.interface.print("[cyan]Opening browser for authentication...[/cyan]")
            session_id = auth.create_session()
            auth.open_browser(session_id)

            self.interface.print("[dim]Waiting for approval...[/dim]")

            # --- NEW: Catch the VPN rejection here ---
            try:
                auth_code = auth.poll_session(session_id)
            except PermissionError as e:
                self.interface.display_error(str(e), title="Login Blocked")
                return

            if not auth_code:
                self.interface.print("[red]Login timed out.[/red]")
                return

            tokens = auth.exchange_auth_code(auth_code)

            if not tokens or tokens.get("error"):
                self.interface.print("\n[bold red]Verification failed.[/bold red]")
                self.interface.print("[cyan]Please login again. Give us two seconds while we verify you.[/cyan]")
                return

            tokens["issued_at"] = time.time()
            save_auth(tokens)

            self.interface.print("[bold green]Login successful! You are now authenticated.[/bold green]")
            self.interface.display_startup_hint()

    def cmd_model(self, args):
            new_model, new_provider = self.interface.show_model_selector(self.model_name)
            if new_model != self.model_name:
                self.model_name = new_model
                self.provider = new_provider
                logging.info(f"Switched model to {self.model_name}")
                self.interface.display_header(self.model_name, os.getcwd())

    def cmd_reset(self, args):
            self.state.reset()
            self.interface.clear()
            logging.info("Session reset")
            self.interface.display_header(self.model_name, os.getcwd())

    def cmd_unload(self, args):
            if not args:
                self.state.active_file = None
                self.state.loaded_files = {}
                self.state.loaded_paths = {}
                self.interface.print("[dim]  Unloaded all files.[/dim]")
            else:
                fname = args.replace('"', "").replace("'", "").strip()
                found_key = None
                for key in self.state.loaded_files.keys():
                    if key == fname or os.path.basename(key) == fname:
                        found_key = key
                        break

                if found_key:
                    del self.state.loaded_files[found_key]
                    if found_key in self.state.loaded_paths:
                        del self.state.loaded_paths[found_key]

                    self.interface.print(f"[dim]  Unloaded: {found_key}[/dim]")

                    if self.state.active_file and os.path.basename(self.state.active_file) == found_key:
                        self.state.active_file = None
                else:
                    self.interface.print(f"[red]  File '{fname}' is not currently loaded.[/red]")


    def cmd_map(self, args):
            """Triggers a recursive AST scan and displays the Project Blueprint."""
            prompts.clear_file_tree_cache()
            # 1. Visual Folder Tree
            map_directory()
            # 2. AST Blueprint (Project Map)
            self._step_start("Scanning project architecture...")
            blueprint = prompts.get_repo_map_cached(os.getcwd())
            self.interface.display_blueprint(blueprint)

    def cmd_wizard(self, args):
            self.interface.print("[yellow]Wizard is not available in API-only CLI mode yet.[/yellow]")

    def cmd_apply(self, args):
            save_code_to_file(self.state.active_file, self.state.last_generated_code)

    def cmd_cd(self, args):
            if not args:
                return
            try:
                target_path = os.path.abspath(args)
                
                # Security Barrier: check against global PROJECT_ROOT using commonpath
                if os.path.commonpath([config.PROJECT_ROOT, target_path]) != config.PROJECT_ROOT:
                    self.interface.print("[bold red]SECURITY ALERT:[/bold red] You are in sticky root. To disable it, use :exitroot")
                    logging.warning(f"Access denied: {target_path}")
                    return

                os.chdir(target_path)
                self.interface.print(f"[dim]  cwd: {os.getcwd()}[/dim]")
                if os.path.exists(os.path.join(os.getcwd(), ".git")):
                    update_repo_path(os.getcwd())
            except Exception as e:
                self.interface.print(f"[red]{e}[/red]")

    def cmd_pwd(self, args):
            self.interface.print(f"[dim]{os.getcwd()}[/dim]")

    def cmd_build_it(self, args):
            plan_path = os.path.join(os.getcwd(), "PLAN.md")
            if not os.path.exists(plan_path):
                if not args:
                    # No args + no PLAN.md: prompt inline rather than dead-end
                    self.interface.print("[yellow]No PLAN.md found.[/yellow]")
                    description = self.interface.input("[cyan]What do you want to build? > [/cyan]").strip()
                    if not description:
                        self.interface.print("[dim]Cancelled.[/dim]")
                        return
                    self.interface.print("[cyan]>> Rerouting to Architect & Planning Phase...[/cyan]")
                    # "Create ..." guarantees PLAN classification; triggers enhancer → PLAN.md
                    return f"Create {description}"

                self.interface.print("[cyan]>> No PLAN.md found. Rerouting to Architect & Planning Phase...[/cyan]")
                # Returning a string delegates to the AI loop.
                # Prefix with "Create" to guarantee PLAN classification.
                return f"Create {args}"

            try:
                with open(plan_path, "r", encoding="utf-8") as f:
                    plan_content = f.read()
            except Exception as e:
                self.interface.print(f"[red]Failed to read PLAN.md: {e}[/red]")
                return

            self._step_start("Contextifying project and regenerating project_context.txt...")
            if os.path.exists("project_context.txt"):
                try:
                    os.remove("project_context.txt")
                except Exception:
                    pass
            
            self.state.loaded_files.clear()
            self.state.loaded_paths.clear()
            
            project_context = run_contextify(os.getcwd(), save_to_disk=True)
            self.state.loaded_files.update(project_context)
            for rel_path in project_context.keys():
                abs_p = os.path.abspath(rel_path).replace("\\", "/")
                self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
            
            self._step_ok("Context updated. project_context.txt regenerated.")

            api = BridgeyeAPIClient()

            self._step_start("Validating PLAN.md")

            # Detect website builds so the validator applies web-specific completeness
            # criteria (Design Aesthetic, Animation Profile, section/image density mandates)
            # instead of the generic Objective/Architecture/File Structure/Steps check.
            _plan_lower = plan_content.lower()
            _is_web_build = any(
                kw in _plan_lower for kw in ["react", "vite", "tsx", "jsx", "framer-motion", "tailwind"]
            ) or "design aesthetic" in _plan_lower

            # Load registry list to give the Validator "eyes"
            _reg_list = ""
            if _is_web_build:
                import json
                import importlib.util as _val_ilu
                _val_reg_base = None
                _val_spec = _val_ilu.find_spec("skills")
                if _val_spec and _val_spec.submodule_search_locations:
                    _val_reg_base = os.path.join(_val_spec.submodule_search_locations[0], "frontend-web", "registry")
                if not _val_reg_base or not os.path.exists(_val_reg_base):
                    _rt4 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                    _val_reg_base = os.path.join(_rt4, "skills", "frontend-web", "registry")
                    if not os.path.exists(_val_reg_base):
                        _rt3 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
                        _val_reg_base = os.path.join(_rt3, "skills", "frontend-web", "registry")
                if _val_reg_base and os.path.exists(_val_reg_base):
                    _idx_path = os.path.join(_val_reg_base, "registry_index.json")
                    if os.path.exists(_idx_path):
                        try:
                            with open(_idx_path, "r", encoding="utf-8") as _rf:
                                _raw_idx = json.load(_rf)
                                _clean_names = []
                                for _cat, _items in _raw_idx.items():
                                    if isinstance(_items, list):
                                        _clean_names.extend([i.get("name") for i in _items if isinstance(i, dict)])
                                _reg_list = ", ".join(filter(None, _clean_names))
                        except: pass

            try:
                with self.interface.create_loader("Validating implementation plan..."):
                    validation = api.validate_plan(
                        plan_content=plan_content,
                        model=self.model_name,
                        provider=self.provider,
                        is_web_build=_is_web_build,
                        registry_list=_reg_list,
                    )
            except Exception as e:
                self._step_fail("PLAN.md validation failed")
                self.interface.display_error(str(e), title="Validation Failed")
                return

            is_valid = bool((validation or {}).get("is_valid"))
            improved_plan = ((validation or {}).get("improved_plan") or "").strip()

            if is_valid:
                self._step_ok("PLAN.md is build-ready")
            else:
                if improved_plan:
                    self._step_warn("PLAN.md was incomplete, improving it before build")
                    try:
                        with open(plan_path, "w", encoding="utf-8") as f:
                            f.write(improved_plan)
                        plan_content = improved_plan
                        self._step_ok("PLAN.md improved and saved")
                    except Exception as e:
                        self._step_fail("Failed to save improved PLAN.md")
                        self.interface.print(f"[bold red]Failed to update PLAN.md:[/bold red] {e}")
                        return
                else:
                    self._step_fail("PLAN.md is not build-ready")
                    self.interface.print("[bold red]Plan validation failed: PLAN.md is not build-ready and no improved plan was returned.[/bold red]")
                    return

            # Model Selection logic: Skip UI if only one model is available
            import questionary

            build_models = [
                questionary.Choice(title="Kimi k2.6 - Deep Reasoning (64k Context)", value="moonshotai/kimi-k2.6"),
            ]

            if len(build_models) == 1:
                model_choice = build_models[0].value
            else:
                model_choice = questionary.select(
                    "Who should implement this plan?",
                    choices=build_models
                ).ask()

            if not model_choice:
                return

            self._step_start(f"Starting implementation pass with {model_choice}")
            start_time = time.time()

            prompt = (
                "SYSTEM_OVERRIDE: DISREGARD PREVIOUS PLANNING INSTRUCTIONS.\n"
                "PHASE: IMPLEMENTATION.\n"
                "ACT AS: Senior Lead Developer.\n"
                "TASK: Read the provided PLAN.md and generate the FULL implementation for ALL files.\n\n"
                "STRICT OUTPUT CONTRACT:\n"
                "1. You may emit short progress markers first, each on its own line, using ONLY:\n"
                "   [STATUS] <what you are doing now>\n"
                "   [FILE] <file path>\n"
                "2. After progress markers, output FINAL FILES ONLY.\n"
                "3. Every file MUST follow this exact format with no extra text in between:\n"
                "   [CREATE: path/to/file]\n"
                "   ```python\n"
                "   <full file contents>\n"
                "   ```\n"
                "4. Do not put explanations before, inside, or after [CREATE] blocks.\n"
                "5. Do not use bullets, numbering, commentary, or markdown headings in the final file section.\n"
                "6. Do not stop after progress markers. You must output all final [CREATE: ...] blocks.\n"
                "7. Ensure every [CREATE: ...] tag is immediately followed by a fenced code block.\n"
                "8. If only one file is needed, still use the exact [CREATE: filename] + fenced code block format.\n"
                "9. CRITICAL RULE: PLAN.md is a READ-ONLY planning document. DO NOT output [CREATE: PLAN.md] or [EDIT: PLAN.md]. NEVER write application code into PLAN.md. Write code directly to the appropriate source files.\n\n"
                f"PLAN.md CONTENT:\n{plan_content}"
            )

            self.handle_ai_request_streaming_build(
                prompt_text=prompt,
                plan_content=plan_content,
                override_model=model_choice
            )
            
            duration = time.time() - start_time
            self.interface.print(f"\n[dim]>> Build time ({model_choice.split('/')[-1]}): {duration:.2f}s[/dim]")

    def cmd_exit(self, args):
            logging.info("Shutdown")
            return "EXIT"

    def cmd_load(self, args):
            """
            :load should ONLY load file contents into context.
            It must NOT trigger any AI call.
            """
            try:
                path_arg = (args or "").strip().replace('"', "").replace("'", "")
                if not path_arg:
                    self.interface.print("[yellow]Usage: :load <file>[/yellow]")
                    return

                f_path, content = load_file(path_arg)
                if not f_path:
                    return

                abs_path = os.path.abspath(f_path).replace("\\", "/")
                base = os.path.basename(f_path)

                # mark active (keep absolute path)
                self.state.active_file = abs_path

                # canonical storage: basename only
                self.state.loaded_files[base] = content
                self.state.loaded_paths[base] = abs_path

                self.interface.print(f"[green]>> Loaded into context:[/green] {base}")
                return  # IMPORTANT: do not return a string (prevents AI call)

            except Exception as e:
                self.interface.print(f"[red]Load failed: {e}[/red]")
                return



    def cmd_paste(self, args):
            if not self.state.active_file:
                self.interface.print("[red]Load a file first.[/red]")
                return
            error_log = self.interface.get_multiline_input()
            if error_log:
                return f"DEBUG_REQUEST: Fix {self.state.active_file}\nERROR:\n{error_log}"

    def cmd_run(self, args):
            if not args:
                self.interface.print("[yellow]Usage: run <filename> or run <command>[/yellow]")
                return

            target_file = args.strip().replace('"', "").replace("'", "")
            
            # 1. HTML Handling
            if target_file.lower().endswith((".html", ".htm")):
                import webbrowser
                fpath = os.path.abspath(target_file)
                if os.path.exists(fpath):
                    self.interface.print(f"[green]>> Opening {os.path.basename(fpath)} in browser...[/green]")
                    webbrowser.open(f"file://{fpath}")
                    return
                else:
                    self.interface.print(f"[red]>> File not found: {target_file}[/red]")
                    return

            command_list = shlex.split(args)
            resolved = resolve_path(command_list[0]) if command_list else None

            # 2. Unified Multi-Language Runner & Tool-Chain Logic
            from nova_cli.local.utils import ensure_system_tool
            ensure_tool = ensure_system_tool

            if len(command_list) >= 1:
                first_cmd = command_list[0]
                # If it's a file, resolve it and determine runner
                if os.path.exists(resolved or first_cmd):
                    target = resolved or first_cmd
                    ext = os.path.splitext(target)[1].lower()
                    
                    # 1. Resolve Tool First
                    tool = None
                    if ext == ".py":
                        tool = sys.executable
                    elif ext == ".js":
                        tool = ensure_tool("node")
                    elif ext in [".r", ".R"]:
                        tool = ensure_tool("Rscript")
                    
                    if not tool and ext in [".py", ".js", ".r", ".R"]:
                        return

                    # 2. Run dependency batch installation using the resolved tool path
                    from nova_cli.local.utils import ensure_dependencies
                    ensure_dependencies(target, tool_path=tool)

                    # 3. Finalize Command List
                    if ext == ".py":
                        with open(target, "r", encoding="utf-8") as f:
                            content = f.read()
                            if re.search(r"import\s+streamlit", content):
                                tool = ensure_tool("streamlit")
                                if not tool: return
                                self.interface.print(f"[bold magenta]>> Launching Streamlit Portal...[/bold magenta]")
                                
                                # 1. Format command (Windows Popen with shell=True works best with a string)
                                if sys.platform == "win32":
                                    cmd = f'"{tool}" run "{target}" --server.headless true'
                                else:
                                    cmd = [tool, "run", target, "--server.headless", "true"]

                                # 2. Launch as detached background process
                                subprocess.Popen(
                                    cmd,
                                    shell=(sys.platform == "win32"),
                                    stdout=subprocess.DEVNULL,
                                    stderr=subprocess.DEVNULL,
                                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
                                    start_new_session=True if sys.platform != "win32" else False
                                )
                                
                                # 3. Explicitly trigger browser open
                                import webbrowser
                                from time import sleep
                                
                                # Give the server 3 seconds to initialize before opening the page
                                sleep(3)
                                webbrowser.open("http://localhost:8501")
                                
                                self.interface.print(f"[green]✔ Streamlit server initiated. Opening http://localhost:8501 in browser...[/green]")
                                return
                            elif any(k in content for k in ["Flask", "flask", "django", "FastAPI", "fastapi", "app.run"]):
                                self.interface.print(f"[bold magenta]>> Launching Python Web Server...[/bold magenta]")
                                
                                # Detect port, default to 5000 for Flask or 8000 for others
                                port = "5000" if "flask" in content.lower() else "8000"
                                port_match = re.search(r'port\s*=\s*(\d+)', content)
                                if port_match: port = port_match.group(1)

                                if sys.platform == "win32":
                                    cmd = f'"{sys.executable}" "{target}"'
                                else:
                                    cmd = [sys.executable, target]

                                subprocess.Popen(
                                    cmd,
                                    shell=(sys.platform == "win32"),
                                    stdout=subprocess.DEVNULL,
                                    stderr=subprocess.DEVNULL,
                                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
                                    start_new_session=True if sys.platform != "win32" else False
                                )
                                
                                import webbrowser
                                time.sleep(3)
                                webbrowser.open(f"http://localhost:{port}")
                                self.interface.print(f"[green]✔ Web server initiated. Opening http://localhost:{port} in browser...[/green]")
                                return
                            else:
                                command_list = [sys.executable, target]
                    
                    elif ext == ".js" or ext == ".mjs":
                        tool = ensure_tool("node")
                        if not tool: return
                        # Check for package.json to ensure dependencies
                        if os.path.exists("package.json"):
                            self.interface.print("[dim]>> Node Project detected. Ensuring npm modules...[/dim]")
                            subprocess.call("npm install", shell=True)
                        
                        # Detect if this is an Express/Web server
                        is_web_server = False
                        try:
                            with open(target, "r", encoding="utf-8") as f:
                                js_content = f.read()
                                # Common patterns for Node.js web servers
                                if any(k in js_content for k in ["express", "http.createServer", "app.listen", ".listen("]):
                                    is_web_server = True
                        except Exception:
                            pass

                        if is_web_server:
                            self.interface.print(f"[bold magenta]>> Launching Node.js Web Server...[/bold magenta]")
                            
                            # Detect port, default to 3000 if not found
                            port_match = re.search(r'(?:port|PORT|Port)\s*[:=]\s*(\d+)', js_content)
                            port = port_match.group(1) if port_match else "3000"
                            
                            if sys.platform == "win32":
                                cmd = f'"{tool}" "{target}"'
                            else:
                                cmd = [tool, target]

                            # Launch as a background process
                            subprocess.Popen(
                                cmd,
                                shell=(sys.platform == "win32"),
                                stdout=subprocess.DEVNULL,
                                stderr=subprocess.DEVNULL,
                                creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
                                start_new_session=True if sys.platform != "win32" else False
                            )
                            
                            import webbrowser
                            # Give the server a few seconds to boot
                            time.sleep(3) 
                            webbrowser.open(f"http://localhost:{port}")
                            self.interface.print(f"[green]✔ Web server initiated. Opening http://localhost:{port} in browser...[/green]")
                            return

                        command_list = [tool, target]
                    
                    elif ext in [".r", ".R"]:
                        tool = ensure_tool("Rscript")
                        if not tool: return
                        command_list = [tool, target]
                    
                    elif ext == ".ts":
                        ensure_tool("npm")
                        tool = ensure_tool("ts-node")
                        command_list = [tool, target]

                # If it's a direct command call (e.g., run npm install)
                elif first_cmd in ["npm", "npx", "Rscript", "streamlit", "vite"]:
                    tool = ensure_tool(first_cmd)
                    if tool: command_list[0] = tool
                    
                    # Ensure shell tools execute from the current directory
                    current_cwd = os.getcwd()

                    # [FIX]: Trigger environment sync for Node/Vite commands
                    if first_cmd in ["npm", "npx", "vite"]:
                        from nova_cli.local.utils import ensure_dependencies
                        # Passing package.json as a signal to trigger Node-specific global sync
                        ensure_dependencies(os.path.join(os.getcwd(), "package.json"))
            
            # 3. Final Command Validation
            if not command_list:
                self.interface.print("[red]>> Error: Could not determine runner for this file.[/red]")
                return

            # 4. Execute with Healing
            try:
                # [FIX]: Redirect individual file execution to Vite Dev Server
                # Using dynamic import because 'frontend-web' contains a hyphen
                import importlib.util
                _base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
                _scanner_path = os.path.join(_base, "skills", "frontend-web", "dna_scanner.py")
                dna = {"is_vite": False}
                
                if os.path.exists(_scanner_path):
                    _spec = importlib.util.spec_from_file_location("dna_loader_handlers", _scanner_path)
                    _mod = importlib.util.module_from_spec(_spec)
                    _spec.loader.exec_module(_mod)
                    dna = _mod.scan_project_dna(config.PROJECT_ROOT)
                
                if dna.get("is_vite") and target_file.endswith((".ts", ".tsx", ".js", ".jsx")):
                    self.interface.print("[yellow]>> Vite Project detected. Individual file execution is disabled.[/yellow]")
                    self.interface.print("[cyan]>> Redirecting to: npm run dev[/cyan]")
                    command_list = ["npm", "run", "dev"]
                    current_cwd = config.PROJECT_ROOT

                # Use the identified root or current dir
                exec_cwd = current_cwd if 'current_cwd' in locals() else os.getcwd()
                
                output = run_with_healing(
                    command_args=command_list,
                    cwd=exec_cwd,
                    model=self.model_name,
                    provider=self.provider,
                    context=self.state.loaded_files,
                    repo_map=None,
                )

                if output and output != "SUCCESS_SIGNAL":
                    # 1. Always display standard output in a pretty panel
                    self.interface.script_output(output, title=f"Run Output: {os.path.basename(command_list[-1])}")

                    # 2. Prevent Data Interpretation from triggering during web builds/dev servers
                    is_web_command = any(word in str(command_list).lower() for word in ["npm", "vite", "dev", "start"])

                    if not is_web_command:
                        # 3. Advanced Analysis Detection Heuristic
                        analysis_keywords = [
                            "mean", "std", "median", "r-squared", "regression", "coefficient", 
                            "p-value", "correlation", "intercept", "variance", "summary", "count"
                        ]
                        # Detection: keywords + checking for common data patterns like numeric tables
                        is_actual_analysis = any(k in output.lower() for k in analysis_keywords) or \
                                            (re.search(r'\d+\.\d+', output) and len(output.splitlines()) > 5)

                        if is_actual_analysis:
                            import json
                            import time
                            analysis_payload = json.dumps({
                                "filename": os.path.basename(command_list[-1]),
                                "terminal_raw": output,
                                "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
                            })

                            interpret_prompt = (
                                "SYSTEM_OVERRIDE: You are a Senior Data Scientist.\n"
                                "Analyze the following JSON-wrapped terminal output. "
                                "Summarize key statistical findings, identify anomalies, and suggest the next logical step.\n\n"
                                f"DATA_JSON: {analysis_payload}"
                            )

                            from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
                            api = BridgeyeAPIClient()
                            with self.interface.create_loader("NOVA is interpreting data..."):
                                ai_response = api.chat(
                                    prompt=interpret_prompt,
                                    context={},
                                    model="openai/gpt-oss-120b",
                                    provider="openrouter"
                                )
                            
                            if ai_response:
                                self.interface.display_interpretation(ai_response)

            except Exception as e:
                self.interface.print(f"[bold red]Healer Error:[/bold red] {e}")

    def cmd_create(self, args):
            if not args:
                self.interface.print("[yellow]Usage: :create [folder|file] <name>[/yellow]")
                return
            
            parts = args.split(maxsplit=1)
            is_dir = False
            
            if len(parts) == 2 and parts[0].lower() in ["folder", "dir", "directory"]:
                is_dir = True
                target = parts[1].strip()
            elif len(parts) == 2 and parts[0].lower() == "file":
                target = parts[1].strip()
            else:
                target = args.strip()
                if target.endswith("/") or target.endswith("\\") or ("." not in os.path.basename(target) and not target.startswith(".")):
                    is_dir = True

            target = target.replace('"', "").replace("'", "")
            target_path = os.path.abspath(target)
            
            try:
                if os.path.exists(target_path):
                    item_type = "Folder" if is_dir else "File"
                    self.interface.print(f"[bold red]>> {item_type} '{target}' already exists. Do you want to change the folder name?[/bold red]")
                    return

                if is_dir:
                    os.makedirs(target_path, exist_ok=True)
                    self.interface.print(f"[green]>> Created Directory: {target}[/green]")
                else:
                    os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
                    with open(target_path, "a", encoding="utf-8"): pass
                    self.interface.print(f"[green]>> Created File: {target}[/green]")
                
                from nova_cli.local.contextifier import run_contextify
                run_contextify(os.getcwd(), save_to_disk=True)
                prompts.clear_file_tree_cache()
            except Exception as e:
                self.interface.print(f"[red]>> Create Failed: {e}[/red]")

    def cmd_delete(self, args):
            if not args:
                self.interface.print("[yellow]Usage: :delete [folder|file] <name>[/yellow]")
                return
                
            parts = args.split(maxsplit=1)
            if len(parts) == 2 and parts[0].lower() in ["folder", "dir", "directory", "file"]:
                target = parts[1].strip()
            else:
                target = args.strip()
                
            target = target.replace('"', "").replace("'", "")
            from nova_cli.local.file_manager.io_ops import safe_delete
            
            if safe_delete(target):
                from nova_cli.local.contextifier import run_contextify
                run_contextify(os.getcwd(), save_to_disk=True)

    def cmd_rename(self, args):
            if not args:
                self.interface.print("[yellow]Usage: :rename <old_name> <new_name>[/yellow]")
                return
            
            parts = shlex.split(args)
            if len(parts) < 2:
                self.interface.print("[yellow]Usage: :rename <old_name> <new_name>[/yellow]")
                return
            
            old_name = parts[0]
            new_name = parts[1]
            
            from nova_cli.local.file_manager.io_ops import safe_rename
            if safe_rename(old_name, new_name):
                # Context Awareness: Update internal state if the renamed file was loaded
                old_base = os.path.basename(old_name)
                new_base = os.path.basename(new_name)
                
                if old_base in self.state.loaded_files:
                    content = self.state.loaded_files.pop(old_base)
                    self.state.loaded_files[new_base] = content
                    
                    # Update paths
                    old_abs = self.state.loaded_paths.pop(old_base, None)
                    if old_abs:
                        new_abs = os.path.join(os.path.dirname(old_abs), new_base).replace("\\", "/")
                        self.state.loaded_paths[new_base] = new_abs
                        
                        # Update active file pointer if necessary
                        if self.state.active_file == old_abs:
                            self.state.active_file = new_abs
                    
                    self.interface.print(f"[dim]>> Synchronized AI context: {old_base} is now {new_base}[/dim]")

                from nova_cli.local.contextifier import run_contextify
                run_contextify(os.getcwd(), save_to_disk=True)

    def cmd_clean(self, args):
            if not self.state.loaded_files:
                self.interface.print("[yellow]No files loaded. Use :load first.[yellow]")
                return

            api = BridgeyeAPIClient()
            repo_map = prompts.get_repo_map_cached(os.getcwd())

            for filename, content in self.state.loaded_files.items():
                try:
                    self.interface.print(f"[dim]Refactoring {filename}...[/dim]")

                    resp = api.refactor(
                        filename=filename,
                        content=content,
                        model="moonshotai/kimi-k2.6",
                        provider="openrouter",
                        repo_map=repo_map
                    )

                    file_path = (
                        self.state.loaded_paths.get(filename)
                        or (
                            self.state.active_file
                            if self.state.active_file and os.path.basename(self.state.active_file) == filename
                            else filename
                        )
                    )

                    # --- CASE 1: Janitor returned a Nova [EDIT] patch ---
                    if isinstance(resp, str) and "[EDIT:" in resp:
                        # Quick client-side sanity check before applying
                        if "<<<<<<<" not in resp or "=======" not in resp or ">>>>>>>" not in resp:
                            self.interface.print(
                                f"[bold red]Invalid Janitor patch format for {filename} (missing SEARCH/REPLACE markers).[/bold red]"
                            )
                            self.interface.print((resp or "")[:2000])
                            continue

                        modified = handle_ai_commands(resp)

                        if not modified:
                            self.interface.print(
                                f"[bold red]Janitor returned an [EDIT] block but it could not be applied for {filename}.[/bold red]"
                            )
                            self.interface.print((resp or "")[:2000])
                            continue

                        # Reload file after successful patch
                        if os.path.exists(file_path):
                            with open(file_path, "r", encoding="utf-8") as f:
                                new_code = f.read()
                            self.state.loaded_files[filename] = new_code

                        self.interface.print(f"[green]✔ Refactored {filename}[/green]")
                        continue

                    # --- CASE 2: Server returned plain refactored code (fallback path) ---
                    # This should almost never happen now that Janitor is strict,
                    # but we keep it as a safety fallback.
                    if not isinstance(resp, str):
                        raise ValueError(f"Unexpected response type from Janitor: {type(resp)}")

                    new_code = resp or ""

                    with open(file_path, "w", encoding="utf-8") as f:
                        f.write(new_code)

                    self.state.loaded_files[filename] = new_code
                    self.interface.print(f"[green]✔ Refactored {filename}[/green]")

                except Exception as e:
                    self.interface.print(f"[bold red]Janitor API error for {filename}:[/bold red] {e}")    

    def cmd_continue(self, args):
            """Resumes an interrupted build from the last failed file."""
            if not self.state.pending_build_files:
                self.interface.print("[yellow]>> No pending build queue found.[/yellow]")
                return

            model = self.state.current_build_model or "moonshotai/kimi-k2.6"
            self.interface.print(f"[cyan]>> Resuming build with {model}: {len(self.state.pending_build_files)} files remaining...[/cyan]")
            self.interface.display_coding_mode(model)
            self._execute_build_loop(override_model=model)

    def cmd_remember(self, args):
            if not args or not args.strip():
                self.interface.print("[yellow]Usage: :remember <note text>[/yellow]")
                return
            from nova_cli.local.memory import add_manual_note
            add_manual_note(os.getcwd(), args.strip())
            self.interface.print("[green]>> Noted.[/green]")

    def cmd_consolidate(self, args):
            from nova_cli.local.memory import consolidate_now
            with self.interface.create_loader("Consolidating project memory..."):
                did_run = consolidate_now(os.getcwd())
            if did_run:
                self.interface.print("[green]>> Memory consolidated.[/green]")
            else:
                self.interface.print("[yellow]>> Nothing to consolidate yet.[/yellow]")

--- FILE: cli/shell_parts/ui_utils.py ---

import os
import re
import sys

from nova_cli import config
def extract_enhanced_prompt(raw_output: str) -> str:
        import re
        # Strip model special tokens e.g. <|end|>, <|start|>, <|channel|>
        raw_output = re.sub(r'<\|[^|>]*\|>', '', raw_output)
        # Case 1: Both tags present
        matches = re.findall(r"<enhanced_prompt\s*>(.*?)</enhanced_prompt\s*>", raw_output, re.DOTALL | re.IGNORECASE)
        if matches:
            return matches[-1].strip()
        # Case 2: Opening tag only
        match = re.search(r".*<enhanced_prompt\s*>(.*)", raw_output, re.DOTALL | re.IGNORECASE)
        if match:
            return match.group(1).strip()
        # Case 3: No tags — thinking uses plain "Task:", actual content uses **Task** (bold)
        idx = raw_output.find('**Task**')
        if idx != -1:
            return raw_output[idx:].strip()
        return raw_output.strip()
class ShellUIUtilsMixin:
    

    def get_prompt_text(self):
        prefix = ""
        if self.state.active_file:
            prefix = f"[dim]({os.path.basename(self.state.active_file)})[/dim] "
        if len(self.state.loaded_files) > 0:
            prefix += f"[dim][{len(self.state.loaded_files)} loaded][/dim] "
        
        # Color changes to RED in overdrive mode
        prompt_color = "bold red" if config.OVERDRIVE else "bold cyan"
        overdrive_indicator = "[bold red]O[/bold red] " if config.OVERDRIVE else ""
        
        return f"{prefix}{overdrive_indicator}[{prompt_color}]spark terminal >[/{prompt_color}]  "

    def _step_start(self, message: str):
            self.interface.print(f"[cyan]• {message}[/cyan]")

    def _step_ok(self, message: str):
            self.interface.print(f"[green]✓ {message}[/green]")

    def _step_warn(self, message: str):
            self.interface.print(f"[yellow]⚠ {message}[/yellow]")

    def _step_fail(self, message: str):
            self.interface.print(f"[red]✗ {message}[/red]")

    def scan_and_load_context(self, text):
                potential_files = re.findall(r"\b[\w\-\/]+\.\w+\b", text)

                loaded_any = False
                for fname in potential_files:
                    if os.path.exists(fname) and os.path.isfile(fname):
                        try:
                            abs_path = os.path.abspath(fname).replace("\\", "/")
                            base = os.path.basename(fname)

                            with open(fname, "r", encoding="utf-8") as f:
                                self.state.loaded_files[base] = f.read()
                            self.state.loaded_paths[base] = abs_path

                            if not self.state.active_file:
                                self.state.active_file = abs_path

                            loaded_any = True
                        except Exception:
                            pass

                if self.state.active_file and os.path.exists(self.state.active_file):
                    try:
                        base = os.path.basename(self.state.active_file)
                        with open(self.state.active_file, "r", encoding="utf-8") as f:
                            self.state.loaded_files[base] = f.read()
                        self.state.loaded_paths[base] = os.path.abspath(self.state.active_file).replace("\\", "/")
                    except Exception:
                        pass

                if loaded_any:
                    self.interface.print("[dim]>> Auto-loaded file context for AI visibility.[/dim]") 

--- FILE: local/file_manager.py ---

# Facade module to preserve old imports:
# import modules.file_manager as file_manager

from nova_cli.local.file_manager import *  # noqa: F401,F403


--- FILE: local/ui.py ---

from rich.console import Console, Group
from rich.panel import Panel
from rich.markdown import Markdown
from rich.live import Live
from rich.table import Table
from rich.rule import Rule
from rich import box
import os
import re
import questionary
import requests
from questionary import Separator
from nova_cli import __version__, config
from nova_cli.nova_core.ai.utils import MODELS  # CLI model selector list
from nova_cli.local.file_manager.git_ops import get_repo

class Interface:
    def __init__(self):
        # Removed fixed width=120 to allow responsiveness to terminal size
        self.console = Console(force_terminal=True)
        self._update_status = None

    def print(self, *args, **kwargs):
        # We set soft_wrap=True as default unless specifically told otherwise
        if "soft_wrap" not in kwargs:
            kwargs["soft_wrap"] = True
        self.console.print(*args, **kwargs)

    def input(self, prompt_text):
        """Renders prompt with Rich and uses prompt_toolkit for perfect scrolling and wrapping."""
        from prompt_toolkit import prompt
        from prompt_toolkit.formatted_text import ANSI
        
        with self.console.capture() as capture:
            self.console.print(prompt_text, end="")
        raw_prompt = capture.get()
        
        try:
            return prompt(ANSI(raw_prompt))
        except (EOFError, KeyboardInterrupt):
            raise
    
    def script_output(self, text: str, title: str = "Terminal Output", color: str = "white"):
        """Displays script output in a structured, pretty terminal panel."""
        if not text or not text.strip():
            return
        
        from rich.text import Text
        # Use markup=False to preserve raw data/logs exactly as they appeared
        content = Text(text.strip(), style=color) 
        
        self.console.print(Panel(
            content,
            title=f"[bold]{title}[/bold]",
            border_style="bright_black",
            padding=(1, 2),
            subtitle="[dim]Execution complete[/dim]",
            subtitle_align="right"
        ))

    def display_error(self, message: str, title: str = "System Error"):
        """Renders a prettified error message, hiding technical noise."""
        # Clean JSON structures and raw error codes
        clean_msg = str(message)
        if "{" in clean_msg and "}" in clean_msg:
            # Extract message from JSON-like strings if possible, else generic fallback
            match = re.search(r"['\"]message['\"]:\s*['\"](.*?)['\"]", clean_msg)
            clean_msg = match.group(1) if match else "An unexpected internal error occurred."
        
        # Replace common technical codes with user-friendly language
        if "403" in clean_msg:
            clean_msg = "Access denied. Please ensure you are logged in or check your network settings."
        elif "504" in clean_msg or "503" in clean_msg or "500" in clean_msg:
            clean_msg = "The server is currently unreachable or timed out. Please check your internet connection."
        
        # Remove Provider-specific prefixes
        clean_msg = re.sub(r"^[A-Z]+\sProvider\sError:\s*", "", clean_msg)
        clean_msg = re.sub(r"^Error\scode:\s\d+\s-\s*", "", clean_msg)

        # Clean up backend API "(Detail: ...)" wrappers and trace IDs
        detail_match = re.search(r"\(Detail:\s*(.*?)\)", clean_msg)
        if detail_match:
            detail_str = detail_match.group(1).strip()
            # Remove UUID-like trace IDs e.g. [0271153d-8873-429f-85b0-c7710ea88557]
            detail_str = re.sub(r"\[[a-zA-Z0-9\-]{10,}\]\s*", "", detail_str).strip()
            
            if "network connection lost" in detail_str.lower() or "timeout" in detail_str.lower():
                clean_msg = "Network connection lost. Please check your internet connection and try again."
            else:
                clean_msg = f"An error occurred: {detail_str.capitalize()}"

        self.console.print(Panel(
            f"[bold white]{clean_msg}[/bold white]",
            title=f"[bold red] {title} [/bold red]",
            border_style="red",
            padding=(1, 2),
        ))

    def display_interpretation(self, interpretation_text: str):
        """Displays AI-generated data insights in a specialized panel."""
        if not interpretation_text:
            return
        self.console.print(Panel(
            Markdown(interpretation_text.strip()),
            title="[bold magenta]NOVA DATA INTERPRETATION[/bold magenta]",
            border_style="magenta",
            padding=(1, 2)
        ))

    def create_loader(self, text=""):
        # Bigger dots12 spinner matching spark terminal bold cyan color
        return self.console.status(
            f"[bold cyan]{text}[/bold cyan]",
            spinner="dots12",
            spinner_style="cyan",
            speed=1.0
        )

    def show_model_selector(self, current_model: str):
        # Build (provider, model) pairs so we can return both
        options = []
        for provider, models in MODELS.items():
            for m in models:
                options.append((provider, m))

        # Show only model names in UI, but keep provider in the value
        choices = [
            questionary.Choice(title=model, value=(provider, model))
            for provider, model in options
        ]

        # Default selection
        default_value = None
        for provider, model in options:
            if model == current_model:
                default_value = (provider, model)
                break

        answer = questionary.select(
            "Model:",
            choices=choices,
            default=default_value,
            style=questionary.Style([
                ("qmark", "fg:#00ffff bold"),
                ("question", "fg:#ffffff bold"),
                ("answer", "fg:#00ffff bold"),
                ("pointer", "fg:#00ffff bold"),
                ("selected", "fg:#00ffff"),
            ]),
        ).ask()

        if not answer:
            # keep current model, assume openrouter if unknown
            return current_model, "openrouter"

        provider, model = answer
        return model, provider



    def show_git_options(self, auto_commit, auto_push):
        """Displays the Git Configuration Menu with dynamic Init option."""
        # Check if repo exists to dynamically change the menu
        has_repo = get_repo() is not None

        state_ac = "🟢 ON " if auto_commit else "🔴 OFF"
        state_ap = "🟢 ON " if auto_push else "🔴 OFF"

        choices = [
            Separator("--- AUTOMATION SETTINGS ---"),
            f"Toggle Auto-Commit  [{state_ac}]",
            f"Toggle Auto-Push    [{state_ap}]",
            
            Separator("--- ACTIONS ---"),
        ]

        if not has_repo:
            choices.append("📦 Initialize Git Repository")
        else:
            choices.extend([
                "📝 Manual Commit (Stage & Commit all)",
                "🚀 Push to Origin",
                "⬇️  Pull from Origin",
                "📊 Git Status",
            ])
            
        choices.extend([
            Separator("--- EXIT ---"),
            "🔙 Back to Terminal"
        ])

        answer = questionary.select(
            "Git Operations Center",
            choices=choices,
            style=questionary.Style([
                ('qmark', 'fg:#00ff00 bold'),       
                ('question', 'fg:#ffffff bold'),    
                ('answer', 'fg:#00ff00 bold'),      
                ('pointer', 'fg:#00ff00 bold'),     
                ('selected', 'fg:#00ff00'),
                ('separator', 'fg:#666666'),
            ]),
            use_indicator=True
        ).ask()
        
        if not answer: return "Back"
        if "Initialize" in answer: return "Initialize"
        if "Back" in answer: return "Back"
        if "Auto-Commit" in answer: return "Toggle Auto-Commit"
        if "Auto-Push" in answer: return "Toggle Auto-Push"
        if "Manual Commit" in answer: return "Manual Commit"
        if "Push" in answer: return "Push"
        if "Pull" in answer: return "Pull"
        if "Force Sync" in answer: return "Force Sync"
        if "Git Status" in answer: return "Git Status"
        
        return answer

    def get_multiline_input(self):
        self.print("[dim]Paste code below. Type 'EOF' to finish.[/dim]")
        lines = []
        while True:
            try:
                line = self.console.input()
                if line.strip().upper() == "EOF": break
                lines.append(line)
            except KeyboardInterrupt:
                return ""
        return "\n\n".join(lines)

    def clear(self):
        self.console.clear()

    def display_interpretation(self, interpretation_text: str):
        """Displays AI-generated data insights in a specialized panel."""
        if not interpretation_text:
            return
        self.console.print(Panel(
            Markdown(interpretation_text.strip()),
            title="[bold magenta]NOVA DATA INTERPRETATION[/bold magenta]",
            border_style="magenta",
            padding=(1, 2)
        ))

    def display_coding_mode(self, model_name: str):
        """Displays a high-visibility banner for the selected implementation model."""
        display_name = "KIMI k2.6"
        
        self.console.print(Panel(
            f"[bold white]CORE UPGRADE:[/bold white] [bold purple]{display_name} ACTIVE[/bold purple]\n"
            f"[dim]Mode: Surgical Implementation | Model: {model_name}[/dim]",
            border_style="purple",
            expand=False
        ))

    def display_blueprint(self, blueprint_text: str):
        """Displays the AST-based repository map."""
        from rich.syntax import Syntax
        syntax = Syntax(blueprint_text, "python", theme="monokai", line_numbers=False, word_wrap=True)
        self.console.print(Panel(
            syntax,
            title="[bold cyan]Project Blueprint (Repository Map)[/bold cyan]",
            border_style="cyan",
            padding=(1, 2)
        ))

    def render_ai_response(self, text: str):
        """Renders AI markdown response with high-fidelity formatting."""
        if not text:
            return
        
        # Parse as Markdown to properly render tables, headers, and formatting
        md = Markdown(text.strip())
        
        self.console.print()  # Vertical spacer
        self.console.print(md)
        # Visual divider to separate response from the next input prompt
        self.console.print("[dim]────────────────────────────────────────────────────────────────────────────────[/dim]")

    def render_help_menu(self, sections: dict):
        """Renders a responsive, structured help menu using Tables."""
        table = Table(box=None, show_header=False, padding=(0, 2), expand=True)
        table.add_column("Command", style="bold cyan", no_wrap=True, width=15)
        table.add_column("Description", style="dim")

        for section_title, commands in sections.items():
            table.add_row(f"\n[bold magenta]{section_title}[/bold magenta]")
            for cmd, desc in commands.items():
                table.add_row(cmd, desc)

        self.console.print(Panel(table, title="[bold white]NOVA HELP CENTER[/bold white]", border_style="cyan", padding=(1, 2)))

    def render_ai_response(self, text: str):
        if not text:
            return
        
        # Markdown handles tables and headers responsively
        md = Markdown(text.strip())
        self.console.print() 
        self.console.print(md)
        # Rule automatically expands to fill the current terminal width
        self.console.print(Rule(style="dim"))

    def _get_update_status(self):
        """Checks API URL to toggle Developer Mode or Update notifications."""
        if self._update_status:
            return self._update_status

        api_url = config.NOVA_API_BASE_URL.lower()
        is_localhost = "localhost" in api_url or "127.0.0.1" in api_url

        # 1. Always establish the current version display first
        current_version_display = f"[dim]v{__version__}[/dim]"

        if is_localhost:
            self._update_status = f"[bold green]Local Build[/bold green] {current_version_display}"
            return self._update_status

        # 2. Production / Remote environment: Check for updates on PyPI
        try:
            response = requests.get("https://pypi.org/pypi/nova-bridgeye/json", timeout=1.5)
            latest_version = response.json()["info"]["version"]

            # Helper function to convert "0.1.5.1" into (0, 1, 5, 1) for accurate math comparison
            def parse_ver(v):
                return tuple(map(int, (v.split("."))))

            # Only notify if PyPI version is strictly greater than installed version
            if parse_ver(latest_version) > parse_ver(__version__):
                self._update_status = (
                    f"{current_version_display}  [bold yellow]Update Available: v{latest_version}[/bold yellow]\n"
                    f"[dim]Run: pip install --upgrade nova-bridgeye[/dim]"
                )
            else:
                self._update_status = f"{current_version_display} [dim](Up to date)[/dim]"
        except Exception:
            # Fallback if PyPI is unreachable or parsing fails
            self._update_status = current_version_display

        return self._update_status

    def display_startup_hint(self, logged_in: bool = True):
        """Displays a visually appealing prompt based on login status."""
        if logged_in:
            msg = "Welcome back! Type [bold cyan]help[/bold cyan] to explore available commands and features."
        else:
            msg = "Welcome to [bold cyan]NOVA[/bold cyan]! Type [bold cyan]login[/bold cyan] to connect your account and start building."
            
        self.console.print(Panel(
            msg,
            border_style="bright_black",
            padding=(0, 2),
            expand=False
        ))

    def display_header(self, model_name, cwd):
        self.clear()
        
        # Build Top Header Row (Left: Identity & CWD, Right: Version Status)
        header_table = Table.grid(expand=True)
        # vertical="top" keeps alignment perfect even if there is no update
        header_table.add_column(justify="left", vertical="top")
        header_table.add_column(justify="right", vertical="top")
        
        identity_and_cwd = f"[bold white]NOVA[/bold white] [dim]│[/dim] [cyan]{model_name}[/cyan]\n[dim]{cwd}[/dim]"
        version_info = self._get_update_status()
        
        header_table.add_row(identity_and_cwd, version_info)

        self.console.print(Rule(style="dim"))
        self.console.print(header_table)
        self.console.print(Rule(style="dim"))

    def stream_response(self, chat_generator):
        full_text = ""
        self.print() 
        
        from rich.spinner import Spinner
        spinner = Spinner("dots12", text="[bold cyan]NOVA is thinking...[/bold cyan]", speed=1.5)

        with Live(spinner, refresh_per_second=15, auto_refresh=True, vertical_overflow="visible") as live:
            chunk_counter = 0
            for chunk in chat_generator:
                if isinstance(chunk, dict) and chunk.get("type") == "chunk":
                    # Skip reasoning tokens for clean general chat
                    if chunk.get("is_reasoning"):
                        continue
                    
                    text = chunk.get("text", "")
                    if text:
                        full_text += text
                        chunk_counter += 1
                        if chunk_counter % 4 == 0:
                            live.update(Markdown(full_text))
                elif isinstance(chunk, dict) and chunk.get("type") == "error":
                    raise RuntimeError(chunk.get("error", "Unknown stream error"))
            
            if full_text:
                live.update(Markdown(full_text))
        
        # self.print() # Removed to prevent extra newline between hidden continuations
        return full_text

    def stream_rich_response(self, chat_generator, show_reasoning: bool = True):
        """Beautified, throttled stream to prevent terminal flickering and VS Code crashes."""
        full_content = ""
        thought_content = ""
        self.print()

        from rich.spinner import Spinner
        import time
        spinner = Spinner("dots12", text="[bold cyan]NOVA is thinking...[/bold cyan]", speed=1.5)
        
        last_update = time.time()
        update_interval = 0.08  # Throttle to ~12 FPS for stability

        with Live(spinner, refresh_per_second=12, auto_refresh=False, vertical_overflow="crop") as live:
            for event in chat_generator:
                if event.get("type") == "chunk":
                    text = event.get("text", "")
                    is_reasoning = event.get("is_reasoning", False)

                    if is_reasoning:
                        if show_reasoning:
                            thought_content += text
                    else:
                        full_content += text

                    # Only refresh UI if interval has passed to prevent flickering/crashing
                    curr_time = time.time()
                    if curr_time - last_update > update_interval:
                        display_parts = []
                        
                        # --- 1. FIXED SIZE THINKING BOX ---
                        if thought_content and show_reasoning:
                            # Keep only the last 10 lines to ensure a constant box height
                            thought_lines = thought_content.strip().splitlines()
                            display_thought = "\n".join(thought_lines[-10:]) if len(thought_lines) > 10 else thought_content.strip()
                            
                            display_parts.append(Panel(
                                display_thought, 
                                title="[dim]NOVA Thinking...[/dim]", 
                                border_style="dim", 
                                style="dim",
                                height=12, # Fixed height (10 lines + padding)
                                padding=(0, 1)
                            ))
                        
                        # --- 2. STABLE CODE STREAMING (UNIFIED SYNTAX WINDOWING) ---
                        if full_content:
                            # A. Identify Action & File (Supports CREATE and EDIT)
                            file_tags = re.findall(r"\[(?:CREATE|EDIT):\s*(.*?)\s*\]", full_content)
                            current_file = file_tags[-1] if file_tags else None
                            
                            action = "Building" if "[CREATE:" in full_content else "Editing" if "[EDIT:" in full_content else "Responding"
                            title_text = f" {action}: {current_file} " if current_file else f" Nova {action}... "

                            # B. Determine Language for Highlighting
                            ext = "markdown" # Default to markdown for general responses
                            if current_file and "." in current_file:
                                ext = current_file.split(".")[-1].lower()
                            
                            # C. Prepare Display Slice (Windowing to prevent terminal lag/blinking)
                            slice_size = 3500
                            code_slice = full_content[-slice_size:]
                            
                            # D. Clean Display (Strip raw protocol tags from the live stream)
                            clean_code = re.sub(r"\[(?:CREATE|EDIT):.*?\]", "", code_slice)
                            # Remove language identifiers to keep the UI clean
                            for lang in ["html", "css", "python", "javascript", "json", "bash", "sql", "typescript"]:
                                clean_code = clean_code.replace(f"```{lang}", "")
                            clean_code = clean_code.replace("```", "").strip()
                            
                            # E. Render with Syntax Highlighting or Markdown Window
                            if action in ["Building", "Editing"]:
                                from rich.syntax import Syntax
                                display_render = Syntax(
                                    clean_code, 
                                    ext, 
                                    theme="monokai", 
                                    background_color="default", 
                                    word_wrap=True,
                                    line_numbers=False
                                )
                            else:
                                # For general chat, still use a windowed Markdown to prevent blinking
                                display_render = Markdown(clean_code)
                            
                            # F. Wrap in the stabilized FIXED-SIZE Panel
                            display_parts.append(Panel(
                                display_render,
                                title=f"[bold white]{title_text}[/bold white]",
                                subtitle=f"[dim] {len(full_content)} bytes [/dim]" if len(full_content) > slice_size else None,
                                border_style="cyan",
                                padding=(0, 1),
                                height=22 # Fixed height to prevent UI jumping
                            ))
                        
                        if not display_parts:
                            live.update(spinner)
                        else:
                            # Grouping prevents full-terminal rewrites, stopping the "blinking"
                            live.update(Group(*display_parts))
                        
                        live.refresh()
                        last_update = curr_time
                elif event.get("type") == "error":
                    err_msg = event.get("error", "")
                    err_msg_lower = err_msg.lower()
                    if any(k in err_msg_lower for k in ["rate_limit", "413", "tpm"]):
                        raise RuntimeError("The model is very busy due to high demand. Please switch to another model using :model.")
                    if "504" in err_msg_lower:
                        raise RuntimeError("Connection timed out. Please check your internet and try again.")
                    if "403" in err_msg_lower:
                        raise RuntimeError("Access denied. Please check your internet connection or login status.")
                    
                    # Expose the actual error details so we aren't blind
                    raise RuntimeError(f"API Error: {err_msg}")

            # --- FINAL FLUSH FOR SHORT RESPONSES ---
            # Ensures the panel renders even if the response completes faster than the throttle interval
            display_parts = []
            if thought_content and show_reasoning:
                thought_lines = thought_content.strip().splitlines()
                display_thought = "\n".join(thought_lines[-10:]) if len(thought_lines) > 10 else thought_content.strip()
                display_parts.append(Panel(
                    display_thought, title="[dim]NOVA Thinking...[/dim]", 
                    border_style="dim", style="dim", height=12, padding=(0, 1)
                ))
            if full_content:
                file_tags = re.findall(r"\[(?:CREATE|EDIT):\s*(.*?)\s*\]", full_content)
                current_file = file_tags[-1] if file_tags else None
                action = "Building" if "[CREATE:" in full_content else "Editing" if "[EDIT:" in full_content else "Responding"
                title_text = f" {action}: {current_file} " if current_file else f" Nova {action}... "
                
                ext = "markdown"
                if current_file and "." in current_file:
                    ext = current_file.split(".")[-1].lower()
                
                clean_code = re.sub(r"\[(?:CREATE|EDIT):.*?\]", "", full_content[-3500:])
                for lang in ["html", "css", "python", "javascript", "json", "bash", "sql", "typescript"]:
                    clean_code = clean_code.replace(f"```{lang}", "")
                clean_code = clean_code.replace("```", "").strip()
                
                if action in ["Building", "Editing"]:
                    from rich.syntax import Syntax
                    display_render = Syntax(clean_code, ext, theme="monokai", background_color="default", word_wrap=True, line_numbers=False)
                else:
                    display_render = Markdown(clean_code)
                
                display_parts.append(Panel(
                    display_render, title=f"[bold white]{title_text}[/bold white]",
                    subtitle=f"[dim] {len(full_content)} bytes [/dim]" if len(full_content) > 3500 else None,
                    border_style="cyan", padding=(0, 1), height=22
                ))
            
            if display_parts:
                live.update(Group(*display_parts))
                live.refresh()
                
        # Ensure a clean break after implementation tasks
        self.print()
        
        # Fallback: If the model mistakenly outputs everything as reasoning, return the thought content so edits aren't lost.
        final_result = full_content if full_content.strip() else thought_content
        return final_result

ui = Interface()

--- FILE: local/utils.py ---

import re
import ast
import os
from typing import List, Tuple, Optional

def extract_code_from_markdown(md_text: str) -> Optional[str]:
    """
    Finds the most relevant code block. Uses greedy matching to handle nested blocks 
    (e.g. a Markdown plan containing smaller code snippets).
    """
    # Greedy match to capture outermost block if nested
    pattern = r"```(?:\w+)?\s*\n?([\s\S]*)```"
    match = re.search(pattern, md_text)
    
    if match:
        return match.group(1).strip()
    
    # Fallback to non-greedy findall if greedy failed
    pattern_fallback = r"```(?:\w+)?\s*(.*?)```"
    matches = re.findall(pattern_fallback, md_text, re.DOTALL)
    if not matches:
        return None

    candidates = []
    
    # Analyze matches to score them
    for content in matches:
        lines = content.strip().splitlines()
        line_count = len(lines)
        
        # Heuristic: Detect shell commands
        is_shell = False
        first_word = lines[0].strip().split()[0] if lines and lines[0].strip() else ""
        if first_word.lower() in ["pip", "python", "npm", "cd", "ls", "nova", "git", "bash", "sh"]:
            is_shell = True
            
        candidates.append({
            "content": content.strip(),
            "lines": line_count,
            "is_shell": is_shell
        })

    # Filter: If we have multiple blocks, discard short shell blocks
    if len(candidates) > 1:
        filtered = [c for c in candidates if not (c["is_shell"] and c["lines"] < 5)]
        if filtered:
            candidates = filtered

    # Sort by length (descending) - Assume the actual code is the largest chunk
    candidates.sort(key=lambda x: x["lines"], reverse=True)

    return candidates[0]["content"]

def parse_multiple_files(text: str) -> List[Tuple[str, str]]:
    """
    Parses text for multiple [CREATE: filename] ... content pairs.
    
    Robustness: 
    1. Handles bolding/whitespace in tags.
    2. FIRST tries to find a Markdown code block.
    3. FALLBACK: If no code block is found, captures text but STOPS at conversational markers.
    
    Args:
        text (str): The text to parse for [CREATE: filename] ... content pairs.
    
    Returns:
        List[Tuple[str, str]]: A list of tuples containing the filename and content.
    """
    files_to_create: List[Tuple[str, str]] = []
    
    # 1. Split text by the [CREATE: filename] tag
    # This divides the text into [preamble, filename1, content1, filename2, content2...]
    split_pattern = r"(?:\*\*|__)?\[CREATE:\s*(.*?)\s*\](?:\*\*|__)?"
    segments = re.split(split_pattern, text, flags=re.IGNORECASE)
    
    if len(segments) < 3:
        return []

    # Iterate starting from index 1 (first filename), taking steps of 2
    for i in range(1, len(segments), 2):
        filename = segments[i].strip()
        following_text = segments[i+1]
        
        # Clean up filename (remove potential trailing punctuation)
        filename = filename.rstrip(".:,")
        
        # Strategy A: Look for explicit Markdown Code Block (Preferred)
        # We use a greedy match ([\s\S]*) to capture internal code blocks (mermaid, etc.)
        # We then manually strip the very last set of triple backticks if they exist.
        match = re.search(r"```(?:\w+)?\s*\n?([\s\S]*)", following_text)
        
        if match:
            # We capture everything after the first ```
            code = match.group(1).strip()
            
            # IMPROVED LOGIC: Only strip the closing fence if it's the 
            # VERY LAST thing in the segment. This prevents truncating 
            # at internal code blocks (like JSON snippets in a PLAN.md).
            if code.endswith("```"):
                code = code[:-3].strip()
            elif "```" in code:
                # If there's a fence but not at the end, the model likely 
                # stopped mid-sentence or used internal blocks. 
                # We search for the outermost closing fence.
                potential_end = code.rfind("```")
                # If there is conversational text after the last fence, 
                # we cut at the last fence.
                if potential_end != -1:
                    code = code[:potential_end].strip()
                
            files_to_create.append((filename, code))
        else:
            # Strategy B (Fallback): The AI forgot backticks. 
            # We take the raw text and stop ONLY if we see another NOVA tag.
            # This ensures Markdown headers (#) and bold (**) aren't cut off.
            raw_content = following_text.strip()
            
            # If the next tag exists in this block, cut the content there
            next_tag = re.search(r"\[(?:CREATE|EDIT|MKDIR|DELETE):", raw_content, re.IGNORECASE)
            if next_tag:
                raw_content = raw_content[:next_tag.start()].strip()
            
            if raw_content:
                files_to_create.append((filename, raw_content))
            
    return files_to_create

def check_syntax(content: str, filename: str) -> Tuple[bool, str]:
    """
    Verifies if the code content is valid syntax for Python, Node.js, or R.
    """
    import subprocess
    import tempfile

    ext = os.path.splitext(filename)[1].lower()
    
    if ext == ".py":
        try:
            ast.parse(content)
            return True, ""
        except SyntaxError as e:
            return False, f"Line {e.lineno}: {e.msg}"
        except Exception as e:
            return False, str(e)

    elif ext in [".js", ".jsx", ".ts", ".tsx", ".vue"]:
        # 1. HEURISTIC CHECK: Catch common AI fragments/stray chars
        stripped = content.strip()
        if not stripped:
            return False, "File is empty."
        
        # Illegal start characters often produced by LLM reasoning bleed
        if stripped[0] in ["=", ":", ">", "}", "]"]:
            return False, f"Illegal starting character detected: '{stripped[0]}'. File appears to be a code fragment."

        # 2. STRUCTURAL CHECK: Ensure components have at least one import or export
        if ext in [".tsx", ".jsx", ".vue"]:
            if "import" not in content and "export" not in content:
                return False, "Invalid Component: Missing 'import' or 'export' statements."

        # 3. COMPILER CHECK: Dry-run validation
        with tempfile.NamedTemporaryFile(suffix=ext, delete=False, mode='w', encoding='utf-8') as tmp:
            tmp.write(content)
            tmp_path = tmp.name
        try:
            # [FIX]: Use a more robust check for TSX/ESM
            # Node --check often fails on internal ESM loaders.
            # We will use a structural scan to ensure the file is code and not reasoning.
            os.remove(tmp_path)
            
            # Check for the presence of common "hallucination markers"
            forbidden = ["<<<<<<< SEARCH", "=======", ">>>>>>> REPLACE", "```"]
            if any(f in content for f in forbidden):
                return False, "File contains leaked SEARCH/REPLACE markers or markdown fences."
                
            return True, ""
        except Exception:
            return True, ""

    return True, ""

def generate_ast_map(startpath: str) -> str:
    """
    Scans the directory and generates a high-level map of the code structure
    (Classes and Functions) using AST, skipping bodies to save tokens.
    """
    repo_map = []
    
    excluded_dirs = {
        ".git", "__pycache__", "venv", "env", "node_modules", 
        ".idea", ".vscode", "dist", "build", ".next", ".ds_store", 
        ".mypy_cache", ".nova"
    }

    for root, dirs, files in os.walk(startpath):
        # Filter directories in-place
        dirs[:] = [d for d in dirs if d not in excluded_dirs and not d.startswith(".")]
        
        # Define extensions we want to show in the map (Filenames only for data files)
        trackable_extensions = (".py", ".html", ".css", ".js", ".ts", ".r", ".R", ".json", ".md", ".csv", ".xlsx", ".parquet")

        for file in files:
            if not file.endswith(trackable_extensions):
                continue
                
            full_path = os.path.join(root, file)
            rel_path = os.path.relpath(full_path, startpath).replace("\\", "/")
            
            # Case 1: Python Files (AST parsing for structure)
            if file.endswith(".py"):
                try:
                    with open(full_path, 'r', encoding='utf-8') as f:
                        content = f.read()
                        if not content.strip(): continue
                        tree = ast.parse(content)
                    
                    repo_map.append(f"FILE: {rel_path}")
                    
                    for node in tree.body:
                        if isinstance(node, ast.ClassDef):
                            repo_map.append(f"  class {node.name}:")
                            for sub in node.body:
                                if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and not sub.name.startswith("_"):
                                    args = [a.arg for a in sub.args.args]
                                    sig = f"def {sub.name}({', '.join(args)})"
                                    prefix = "    async " if isinstance(sub, ast.AsyncFunctionDef) else "    "
                                    repo_map.append(f"{prefix}{sig}")
                        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                            args = [a.arg for a in node.args.args]
                            sig = f"def {node.name}({', '.join(args)})"
                            prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
                            repo_map.append(f"  {prefix}{sig}")
                            
                except Exception:
                    repo_map.append(f"FILE: {rel_path} (Parse Error)")
            
            # Case 2: Node.js / R / Web (Lightweight symbol extraction)
            elif file.endswith((".js", ".ts", ".r", ".R")):
                repo_map.append(f"FILE: {rel_path}")
                try:
                    with open(full_path, 'r', encoding='utf-8') as f:
                        lines = f.readlines()
                    for line in lines:
                        # Match JS functions: function name() or const name = () =>
                        js_func = re.search(r'(?:function\s+([\w\d_]+)|(?:const|let|var)\s+([\w\d_]+)\s*=\s*(?:async\s*)?\(.*?\)\s*=>)', line)
                        if js_func:
                            name = js_func.group(1) or js_func.group(2)
                            repo_map.append(f"  function {name}()")
                        
                        # Match R functions: name <- function(...)
                        r_func = re.search(r'([\w\d\._]+)\s*(?:<-|=)\s*function\s*\(', line)
                        if r_func:
                            repo_map.append(f"  function {r_func.group(1)}()")
                except: pass
            else:
                repo_map.append(f"FILE: {rel_path}")
                
    return "\n".join(repo_map)

def stitch_code_blocks(original: str, continuation: str) -> str:
    """
    Stitches two code chunks together, removing overlapping lines 
    using a sliding window comparison.
    """
    orig_lines = original.splitlines()
    cont_lines = continuation.splitlines()
    
    if not orig_lines or not cont_lines:
        return original + continuation

    # Look for a match of the last few lines of 'original' in the start of 'continuation'
    # Check window sizes from 5 lines down to 1
    max_window = min(len(orig_lines), len(cont_lines), 5)
    
    overlap_idx = 0
    for w in range(max_window, 0, -1):
        target_suffix = orig_lines[-w:]
        # Check if the start of continuation matches this suffix
        for i in range(min(len(cont_lines), 10)): # Check first 10 lines of cont
            if cont_lines[i:i+w] == target_suffix:
                overlap_idx = i + w
                break
        if overlap_idx > 0: break

    return original + "\n" + "\n".join(cont_lines[overlap_idx:])

def strip_ansi(text: str) -> str:
    """Aggressive cleaner that removes ANSI and broken terminal fragments."""
    if not text: return ""
    import re
    # 1. Standard ANSI Escape codes
    ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
    text = ansi_escape.sub('', text)
    
    # 2. Fix broken fragments (like [1m or [0m appearing as literal text)
    text = re.sub(r'\[\d+(?:\;\d+)*m', '', text)
    
    # 3. Remove Carriage Returns (\r) which cause line overwriting/backspacing artifacts
    text = text.replace('\r', '')
    
    # 4. Keep only printable characters to ensure the UI stays clean
    return "".join(ch for ch in text if ch.isprintable() or ch in "\n\t")

def get_platform_install_cmd(tool_name: str) -> str:
    """Returns the OS-specific installation command for a given tool."""
    import platform
    import sys
    import os
    import shutil
    os_type = platform.system().lower()
    
    # Winget flags: --silent (no UI), --accept-source-agreements, --accept-package-agreements
    win_flags = "--silent --accept-source-agreements --accept-package-agreements"
    
    # Safely resolve brew path on Mac
    brew_cmd = "brew"
    if os_type == "darwin":
        if shutil.which("brew"):
            brew_cmd = "brew"
        elif os.path.exists("/opt/homebrew/bin/brew"):
            brew_cmd = "/opt/homebrew/bin/brew"
        elif os.path.exists("/usr/local/bin/brew"):
            brew_cmd = "/usr/local/bin/brew"
        else:
            brew_cmd = "NEEDS_BREW"

    installers = {
        "node": {
            "windows": f"winget install OpenJS.NodeJS {win_flags}",
            "darwin": f"{brew_cmd} install node" if brew_cmd != "NEEDS_BREW" else "NEEDS_BREW",
            "linux": "sudo apt-get update && sudo apt-get install -y nodejs npm"
        },
        "npm": {
            "windows": f"winget install OpenJS.NodeJS {win_flags}",
            "darwin": f"{brew_cmd} install node" if brew_cmd != "NEEDS_BREW" else "NEEDS_BREW",
            "linux": "sudo apt-get update && sudo apt-get install -y npm"
        },
        "npx": {
            "windows": f"winget install OpenJS.NodeJS {win_flags}",
            "darwin": f"{brew_cmd} install node" if brew_cmd != "NEEDS_BREW" else "NEEDS_BREW",
            "linux": "sudo apt-get update && sudo apt-get install -y nodejs npm"
        },
        "Rscript": {
            "windows": f"winget install RProject.R {win_flags}",
            "darwin": f"{brew_cmd} install r" if brew_cmd != "NEEDS_BREW" else "NEEDS_BREW",
            "linux": "sudo apt-get update && sudo apt-get install -y r-base"
        },
        "streamlit": {
            "windows": f"{sys.executable} -m pip install streamlit",
            "darwin": f"{sys.executable} -m pip install streamlit",
            "linux": f"{sys.executable} -m pip install streamlit"
        }
    }

    if tool_name in installers:
        target_os = "darwin" if os_type == "darwin" else ("windows" if os_type == "windows" else "linux")
        return installers[tool_name].get(target_os, "")
    
    return ""

def ensure_system_tool(name: str) -> Optional[str]:
    """Unified Cross-Platform tool checker and auto-installer."""
    import shutil, sys, os, subprocess
    from nova_cli.local.ui import ui
    
    path = shutil.which(name)
    if not path and sys.platform == "win32":
        path = shutil.which(f"{name}.cmd") or shutil.which(f"{name}.exe")
    
    # Secondary check for standard installation paths if not in PATH
    if not path:
        if sys.platform == "win32":
            if name == "Rscript":
                import glob
                r_paths = glob.glob(r"C:\Program Files\R\R-*\bin\x64\Rscript.exe")
                if r_paths: path = r_paths[0]
            elif name == "node":
                if os.path.exists(r"C:\Program Files\nodejs\node.exe"): path = r"C:\Program Files\nodejs\node.exe"
        elif sys.platform == "darwin":
            if name == "node":
                if os.path.exists("/opt/homebrew/bin/node"): path = "/opt/homebrew/bin/node"
                elif os.path.exists("/usr/local/bin/node"): path = "/usr/local/bin/node"
            elif name == "Rscript":
                if os.path.exists("/opt/homebrew/bin/Rscript"): path = "/opt/homebrew/bin/Rscript"
                elif os.path.exists("/usr/local/bin/Rscript"): path = "/usr/local/bin/Rscript"
                elif os.path.exists("/Library/Frameworks/R.framework/Resources/bin/Rscript"): path = "/Library/Frameworks/R.framework/Resources/bin/Rscript"

    if path: return path

    install_cmd = get_platform_install_cmd(name)
    
    # Auto-Install Homebrew if missing on Mac
    if install_cmd == "NEEDS_BREW":
        ui.print("[bold yellow]>> Homebrew is required but missing. Installing Homebrew first...[/bold yellow]")
        ui.print("[dim](You may be prompted to enter your Mac password or press RETURN during this process)[/dim]")
        
        brew_script = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
        try:
            res = subprocess.run(brew_script, shell=True)
            if res.returncode != 0:
                ui.print("[red]>> Homebrew installation failed or was cancelled.[/red]")
                return None
            ui.print("[bold green]>> Homebrew installed successfully![/bold green]")
            
            # Re-fetch the command now that brew is installed
            install_cmd = get_platform_install_cmd(name)
            if install_cmd == "NEEDS_BREW":
                ui.print("[red]>> Failed to locate Homebrew binary after installation.[/red]")
                return None
        except Exception as e:
            ui.print(f"[red]>> Failed to execute Homebrew installer: {e}[/red]")
            return None

    if not install_cmd:
        ui.print(f"[red]Fatal: '{name}' is required but no auto-installer exists for this OS.[/red]")
        return None

    ui.print(f"[bold cyan]>> Environment Sync: '{name}' is required but missing.[/bold cyan]")
    ui.print(f"[dim]Auto-installing via system package manager...[/dim]")
    
    try:
        # Cross-Platform Fix: Do not capture output on Linux/Mac so `sudo` prompts or `brew` progress bars are visible.
        if sys.platform == "win32":
            process = subprocess.run(install_cmd, shell=True, capture_output=True, text=True)
            win_already_installed = str(process.returncode) == "2316632107"
            
            if process.returncode == 0 or win_already_installed:
                ui.print(f"[green]>> '{name}' installation command completed.[/green]")
                refresh_environment_variables()
                
                # Verify it actually installed
                final_path = ensure_system_tool(name) if not path else path # recursive check to grab newly installed path
                return final_path if final_path else name
            else:
                ui.print(f"[red]Installation returned error {process.returncode}:[/red] {process.stderr or process.stdout}")
        else:
            # Linux / macOS execution
            process = subprocess.run(install_cmd, shell=True)
            if process.returncode == 0:
                ui.print(f"[green]>> '{name}' installation command completed.[/green]")
                refresh_environment_variables()
                
                # On Mac, brew bins might not be in PATH instantly, so we return the absolute path we know
                if name == "Rscript":
                    if os.path.exists("/opt/homebrew/bin/Rscript"): return "/opt/homebrew/bin/Rscript"
                    elif os.path.exists("/usr/local/bin/Rscript"): return "/usr/local/bin/Rscript"
                elif name == "node":
                    if os.path.exists("/opt/homebrew/bin/node"): return "/opt/homebrew/bin/node"
                    elif os.path.exists("/usr/local/bin/node"): return "/usr/local/bin/node"
                return name
            else:
                ui.print(f"[red]Installation failed with error code {process.returncode}.[/red]")
                
    except Exception as e:
        ui.print(f"[red]Installation process crashed: {e}[/red]")
    
    return None

def refresh_environment_variables():
    """Reloads PATH from the OS across Windows, Mac, and Linux without restarting."""
    import os
    import sys
    import platform
    
    os_type = platform.system().lower()

    if os_type == "windows":
        try:
            import winreg
            paths = []
            # Pull from User and System Registry
            for hkey, subkey in [(winreg.HKEY_CURRENT_USER, "Environment"), 
                                (winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment")]:
                with winreg.OpenKey(hkey, subkey) as key:
                    try:
                        raw_path, _ = winreg.QueryValueEx(key, "Path")
                        paths.extend(raw_path.split(os.pathsep))
                    except FileNotFoundError:
                        continue
            
            # Merge and de-duplicate
            current_path = os.environ.get("PATH", "").split(os.pathsep)
            updated_path = list(dict.fromkeys(current_path + paths)) 
            os.environ["PATH"] = os.pathsep.join(updated_path)
        except Exception:
            pass
            
    elif os_type in ["darwin", "linux"]:
        # Standard binary locations that installers (brew/apt) target
        standard_bins = [
            "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin",
            "/opt/homebrew/bin", "/opt/homebrew/sbin", # Mac Brew
            os.path.expanduser("~/.local/bin"),        # Linux/Python user bins
            os.path.expanduser("~/bin")
        ]
        current_path = os.environ.get("PATH", "").split(os.pathsep)
        updated_path = list(dict.fromkeys(current_path + standard_bins))
        os.environ["PATH"] = os.pathsep.join(updated_path)

def ensure_dependencies(file_path: str, tool_path: str = None):
    """Universal entry point to scan and install dependencies for Python, R, and Node."""
    filename = os.path.basename(file_path).lower()
    ext = os.path.splitext(file_path)[1].lower()
    
    if ext == ".py":
        _ensure_python_deps(file_path)
    elif ext in [".r", ".R"]:
        _ensure_r_deps(file_path, tool_path)
    elif ext in [".js", ".jsx", ".ts", ".tsx", ".vue"] or filename == "package.json":
        _ensure_node_deps(file_path, tool_path)

def _ensure_python_deps(file_path: str):
    import importlib.metadata
    import sys
    import subprocess
    from nova_cli.local.ui import ui

    ui.print(f"[dim]>> Pre-scanning Python dependencies for {os.path.basename(file_path)}...[/dim]")
    with open(file_path, "r", encoding="utf-8") as f:
        content = f.read()

    imports = re.findall(r"^(?:import|from)\s+([\w\d_]+)", content, re.MULTILINE)
    unique_imports = set(imports)
    std_lib = {"os", "sys", "re", "time", "json", "datetime", "math", "random", "shutil", "subprocess", "shlex", "ast", "logging", "io", "base64", "collections", "itertools", "functools", "pathlib", "threading", "queue", "pickle", "csv", "warnings", "argparse", "abc", "typing", "statistics"}
    
    missing = []
    for mod in unique_imports:
        if mod in std_lib: continue
        try:
            importlib.metadata.version(mod)
        except importlib.metadata.PackageNotFoundError:
            mapping = {"sklearn": "scikit-learn", "cv2": "opencv-python", "PIL": "Pillow", "yaml": "pyyaml"}
            missing.append(mapping.get(mod, mod))
            
    if missing:
        ui.print(f"[bold cyan]>> Environment Check: Found {len(missing)} missing Python packages.[/bold cyan]")
        try:
            subprocess.check_call([sys.executable, "-m", "pip", "install"] + missing)
            ui.print("[bold green]>> Python environment synchronized.[/bold green]")
        except Exception:
            for pkg in missing:
                try: subprocess.check_call([sys.executable, "-m", "pip", "install", pkg], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
                except Exception: pass

def _ensure_r_deps(file_path: str, tool_path: str = None):
    import os
    import tempfile
    import subprocess
    from nova_cli.local.ui import ui
    
    ui.print(f"[dim]>> Pre-scanning R dependencies for {os.path.basename(file_path)}...[/dim]")
    
    with open(file_path, "r", encoding="utf-8") as f:
        content = f.read()
    
    # Catch standard library/require calls (including requireNamespace)
    pkgs1 = re.findall(r"(?:library|require|requireNamespace)\s*\(\s*['\"]?([\w\d\.]{2,})['\"]?\s*[,)]", content)
    
    # Catch dynamic vector arrays used for package loading (e.g. pkgs <- c("dplyr", "ggplot2"))
    pkgs2 = []
    vector_matches = re.findall(r"(?:pkgs|packages|libs|reqs)\s*(?:<-|=)\s*c\s*\((.*?)\)", content, re.IGNORECASE)
    for vm in vector_matches:
        pkgs2.extend(re.findall(r"['\"]([^'\"]{2,})['\"]", vm))
        
    pkgs = pkgs1 + pkgs2
    if pkgs:
        # Filter out any lingering 1-character package names or known false positives
        unique_pkgs = [p for p in list(set(pkgs)) if len(p) > 1 and p.lower() not in ['true', 'false', 'null', 'na']]
        ui.print(f"[bold cyan]>> Environment Check: Ensuring R packages: {', '.join(unique_pkgs)}[/bold cyan]")
        
        # Use the resolved tool_path if provided, else fallback to 'Rscript'
        r_bin = f'"{tool_path}"' if tool_path else "Rscript"
        
        # Build vector of requested packages
        pkgs_str = "c(" + ", ".join([f"'{p}'" for p in unique_pkgs]) + ")"
        
        # Use a temporary file to completely bypass Windows/Linux shell quoting issues
        r_code = (
            "dir.create(Sys.getenv('R_LIBS_USER'), showWarnings=FALSE, recursive=TRUE)\n"
            ".libPaths(Sys.getenv('R_LIBS_USER'))\n"
            f"new_pkgs <- {pkgs_str}[!( {pkgs_str} %in% installed.packages()[,'Package'] )]\n"
            "if(length(new_pkgs)) {\n"
            "  install.packages(new_pkgs, repos='https://cloud.r-project.org', lib=.libPaths()[1], quiet=TRUE)\n"
            "}\n"
        )
        
        with tempfile.NamedTemporaryFile(suffix=".R", delete=False, mode='w', encoding='utf-8') as tmp:
            tmp.write(r_code)
            tmp_path = tmp.name
            
        try:
            # Execute directly, using shell=True on Windows to prevent [WinError 2]
            r_bin_clean = tool_path if tool_path else "Rscript"
            res = subprocess.call([r_bin_clean, tmp_path], shell=(os.name == "nt"))
            
            if res == 0:
                ui.print("[bold green]>> R environment synchronized (User Library).[/bold green]")
            else:
                ui.print("[bold red]>> Failed to install R packages. Please check your R installation.[/bold red]")
        finally:
            try:
                os.remove(tmp_path)
            except Exception:
                pass

def _ensure_node_deps(file_path: str, tool_path: str = None):
    import subprocess
    import json
    from nova_cli.local.ui import ui
    from nova_cli import config
    
    # Find the package.json starting from the file_path's directory
    if os.path.isdir(file_path):
        base_dir = file_path
    else:
        base_dir = os.path.dirname(os.path.abspath(file_path))
        
    pkg_json_path = None
    curr = base_dir
    while True:
        candidate = os.path.join(curr, "package.json")
        if os.path.exists(candidate):
            pkg_json_path = candidate
            break
        parent = os.path.dirname(curr)
        if parent == curr:
            break
        curr = parent
        
    if not pkg_json_path:
        return

    root = os.path.dirname(pkg_json_path)

    ui.print(f"[dim]>> Syncing Environment: Scanning for missing Node modules...[/dim]")
    
    try:
        with open(pkg_json_path, "r") as f:
            pkg_data = json.load(f)
            installed = {**pkg_data.get("dependencies", {}), **pkg_data.get("devDependencies", {})}
    except Exception:
        installed = {}

   # 2. Global Scan: Find all imports and check for test files
    all_imports = set()
    has_tests = False
    
    # Identify all target files including root config files (e.g., vite.config.ts) and src files
    target_files = []
    try:
        for f in os.listdir(root):
            fpath = os.path.join(root, f)
            if os.path.isfile(fpath) and f.endswith((".js", ".jsx", ".ts", ".tsx", ".vue")):
                target_files.append((root, f))
    except Exception:
        pass

    scan_dir = os.path.join(root, "src") if os.path.exists(os.path.join(root, "src")) else root
    if scan_dir != root:
        for r, _, files in os.walk(scan_dir):
            for f in files:
                target_files.append((r, f))
    else:
        for r, dirs, files in os.walk(root):
            dirs[:] = [d for d in dirs if d not in ["node_modules", ".git", ".nova"]]
            for f in files:
                if (r, f) not in target_files:
                    target_files.append((r, f))

    for r, f in target_files:
        # Detect testing files to trigger global type installation
        if ".test." in f or ".spec." in f:
            has_tests = True

        if f.endswith((".js", ".jsx", ".ts", ".tsx", ".vue")):
            try:
                with open(os.path.join(r, f), "r", encoding="utf-8") as fp:
                    content = fp.read()
                    # Extract package names (ignoring local relative imports starting with .)
                    matches = re.findall(r"(?:import|from|require)\s*\(?\s*['\"](?!\.)([\w\d\@\-\/]+)['\"]", content)
                    for m in matches:
                        # Skip TypeScript/Vite local path aliases (e.g., "@/routes", "@/components")
                        if m.startswith("@/") or m == "@":
                            continue
                        # Handle scoped packages (e.g., @react-router/dom)
                        parts = m.split("/")
                        package_name = f"{parts[0]}/{parts[1]}" if m.startswith("@") and len(parts) > 1 else parts[0]
                        all_imports.add(package_name)
            except Exception:
                continue

    # 3. Identify and Install Missing (Physical Disk Check)
    built_ins = {"path", "fs", "os", "http", "https", "crypto", "stream", "util", "url", "events", "process", "buffer"}
    
    # Auto-inject testing dependencies if test files are present
    if has_tests:
        all_imports.update(["jest", "@types/jest", "ts-jest"])

    missing = []
    
    node_modules_path = os.path.join(root, "node_modules")
    
    for m in all_imports:
        if m in built_ins:
            continue
        
        # Check if package exists physically on disk
        pkg_path = os.path.join(node_modules_path, m)
        if not os.path.exists(pkg_path):
            missing.append(m)

    # Hardcode AI missing peer dependency fallbacks for popular libraries
    if "recharts" in all_imports or "recharts" in missing:
        if "react-is" not in missing and not os.path.exists(os.path.join(node_modules_path, "react-is")):
            missing.append("react-is")
        if "prop-types" not in missing and not os.path.exists(os.path.join(node_modules_path, "prop-types")):
            missing.append("prop-types")

    if missing:
        # Deduplicate
        missing = list(set(missing))
        ui.print(f"[bold cyan]>> Environment Check: Found {len(missing)} missing packages: {', '.join(missing)}[/bold cyan]")
        npm_bin = "npm.cmd" if os.name == "nt" else "npm"
        try:
            # Install missing packages, using --force to bypass ERESOLVE while still downloading peer dependencies
            subprocess.check_call([npm_bin, "install", "--force"] + missing, cwd=root, shell=(os.name == "nt"))
            ui.print("[bold green]>> Node environment synchronized.[/bold green]")
        except Exception as e:
            ui.print(f"[red]>> Automated install failed: {e}. Please run 'npm install' manually.[/red]")

--- FILE: local/contextifier/__init__.py ---

from .engine import run_contextify

--- FILE: local/contextifier/engine.py ---

import os

IGNORE_DIRS = {
    ".git", "node_modules", "dist", "build", ".vscode", 
    "__pycache__", ".idea", "venv", ".venv", "env", ".env", 
    ".dart_tool", ".pub-cache", ".pytest_cache", ".mypy_cache", ".venv_obf",
    ".venv_build", "release", "obf", "pyarmor_runtime_000000", "pkg_root",
    "assets", ".nova", "project_context.txt",
    ".next", ".nuxt", ".svelte-kit", "public", "static", "coverage"
}

IGNORE_EXTENSIONS = {
    ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp", ".pdf", 
    ".lock", ".log", ".zip", ".tar", ".gz", ".ds_store", ".pyc",
    ".arb", ".exe", ".bin", ".dll", ".so", ".dylib", ".class", 
    ".csv", ".xlsx", ".json", ".rds", ".rda", ".rdata", ".parquet", ".sqlite", ".db",
    ".map", ".trace", ".cache", ".mp4", ".mp3", ".wav"
}

IGNORE_FILES = {
    "project_context.txt",
    "PLAN.md",
    "package-lock.json",
    "yarn.lock",
    "pnpm-lock.yaml",
    "bun.lockb"
}

def is_binary_file(filepath, chunk_size=1024):
    try:
        with open(filepath, 'rb') as f:
            chunk = f.read(chunk_size)
            if b'\0' in chunk:
                return True
        return False
    except Exception:
        return True

def generate_tree(startpath, ignore_dirs):
    tree_lines = []
    for root, dirs, files in os.walk(startpath, topdown=True):
        dirs[:] = [d for d in dirs if d not in ignore_dirs]
        level = root.replace(startpath, '').count(os.sep)
        indent = ' ' * 4 * level
        folder_name = os.path.basename(root)
        if folder_name == '': folder_name = "."
        tree_lines.append(f"{indent}{folder_name}/")
        subindent = ' ' * 4 * (level + 1)
        for f in sorted(files):
            if f in IGNORE_FILES or any(f.lower().endswith(ext) for ext in IGNORE_EXTENSIONS):
                continue
            tree_lines.append(f"{subindent}{f}")
    return "\n".join(tree_lines)

def run_contextify(start_dir='.', verbose_callback=None, save_to_disk=False) -> dict[str, str]:
    """
    Analyzes the project and returns a dictionary of {relative_path: content}
    for all valid files, following the Contextify exclusion logic.
    """
    context_data = {}
    output_buffer = []

    # Tree Section
    if save_to_disk:
        output_buffer.append("Project file structure:\n=======================\n")
        output_buffer.append(generate_tree(start_dir, IGNORE_DIRS))
        output_buffer.append("\n\n\nFile Contents:\n===============\n")

    for root, dirs, files in os.walk(start_dir, topdown=True):
        # Filter directories in-place
        dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
        
        for file in sorted(files):
            if file in IGNORE_FILES or any(file.lower().endswith(ext) for ext in IGNORE_EXTENSIONS):
                continue

            file_path = os.path.join(root, file)
            relative_path = os.path.relpath(file_path, start_dir).replace(os.sep, '/')

            if is_binary_file(file_path):
                continue

            try:
                if verbose_callback:
                    verbose_callback(relative_path)
                with open(file_path, "r", encoding="utf-8", errors="ignore") as infile:
                    content = infile.read()
                    context_data[relative_path] = content
                    if save_to_disk:
                        output_buffer.append(f"\n\n--- FILE: {relative_path} ---\n\n")
                        output_buffer.append(content)
            except Exception:
                continue

    if save_to_disk:
        with open("project_context.txt", "w", encoding="utf-8") as f:
            f.write("".join(output_buffer))

    return context_data

--- FILE: local/file_manager/__init__.py ---

from nova_cli.local.file_manager.git_ops import (
    update_repo_path,
    commit_changes,
    manual_commit,
    perform_push,
    perform_pull,
    git_status,
)

from nova_cli.local.file_manager.path_ops import (
    validate_path,
    resolve_path,
)

from nova_cli.local.file_manager.edit_ops import (
    apply_surgical_edit,
    fuzzy_replace,
    normalize_line,
)

from nova_cli.local.file_manager.io_ops import (
    create_backup,
    map_directory,
    get_project_files,
    run_creation_wizard,
    show_diff,
    safe_delete,
    safe_write,
    save_code_to_file,
    load_file,
)

from nova_cli.local.file_manager.commands import (
    handle_ai_commands,
    is_placeholder_path,
)


--- FILE: local/file_manager/commands.py ---

# nova_cli\local\file_manager\commands.py
import os
import re
from typing import Optional
import shlex
import subprocess

import core.prompts as prompts  # keep as-is for now
from nova_cli import config as cli_config
from nova_cli.local.utils import parse_multiple_files

from nova_cli.local.file_manager.io_ops import safe_delete, safe_write
from nova_cli.local.file_manager.edit_ops import apply_surgical_edit
from nova_cli.local.file_manager.path_ops import validate_path, resolve_path


IGNORE_DIRS = {"path", "folder", "directory", "filename", "your_path", "project_name"}


def _normalize_text(s: str) -> str:
    if s is None:
        return ""
    s = s.replace("\r\n", "\n").replace("\r", "\n")
    s = s.lstrip("\ufeff")  # remove BOM if present
    return s


def _strip_code_fences(s: str) -> str:
    """
    Cleans content by removing Markdown code fences (```python ... ```).
    Also aggressively strips reasoning tags (<thought>, [THOUGHT]) that may have leaked inside.
    """
    s = _normalize_text(s).strip()
    
    # 1. Strip reasoning tags that models like Qwen or Kimi occasionally include
    s = re.sub(r"<(?:/)?thought>", "", s, flags=re.IGNORECASE)
    s = re.sub(r"\[(?:/)?THOUGHT\]", "", s, flags=re.IGNORECASE)
    # Aggressively remove anything inside thought tags if the AI was verbose
    s = re.sub(r"<thought>.*?</thought>", "", s, flags=re.DOTALL | re.IGNORECASE)
    s = re.sub(r"\[THOUGHT\].*?\[/THOUGHT\]", "", s, flags=re.DOTALL | re.IGNORECASE)

    # 2. Artifact Cleanup: Strip leaked surgical markers (SEARCH/REPLACE) if they exist in full file creation
    s = re.sub(r"<{4,10}\s*SEARCH\n?", "", s, flags=re.IGNORECASE)
    s = re.sub(r"={4,10}\n?", "", s)
    s = re.sub(r">{4,10}\s*REPLACE\n?", "", s, flags=re.IGNORECASE)

    # 3. Broad cleanup: Remove any triple backticks + language tags anywhere in the string
    # This prevents fences from breaking SEARCH/REPLACE comparisons
    s = re.sub(r"```[a-zA-Z0-9_+\-]*", "", s)
    s = s.replace("```", "")
    
    # 4. Strip LLM end-of-file artifacts
    s = re.sub(r"\[/?(?:END_FILE|EOF|END|FILE_END)\]", "", s, flags=re.IGNORECASE)
    
    return s.strip()



def is_placeholder_path(path: str) -> bool:
    """Detects documentation placeholders to avoid executing help examples."""
    path_lower = path.lower().replace("\\", "/")
    if path_lower in ["example.py", "file.ext", "path/to/target", "path/to/dir", "path/to/file.ext"]:
        return True
    if "path/to/" in path_lower:
        return True
    return False


def handle_ai_commands(text: str, errors: Optional[list[str]] = None, cwd: Optional[str] = None) -> list[str]:
    # Default anchoring to the current working directory
    cwd = cwd or os.getcwd()
    """
    Parses and executes AI commands ([MKDIR], [DELETE], [EDIT], [CREATE], [SHELL]).
    Returns a list of file paths that were modified.
    """
    import sys
    from nova_cli.local.ui import ui  # Added import here
    modified_files: list[str] = []
    errors = errors or []

    # 1) MKDIR
    mkdir_matches = re.findall(r"(?:\*\*|__)?\[MKDIR:\s*(.*?)\s*\](?:\*\*|__)?", text, re.IGNORECASE)
    folder_created = False

    for folder in mkdir_matches:
        try:
            folder = folder.strip()
            if not folder:
                continue
            if folder.lower() in IGNORE_DIRS:
                continue
            if is_placeholder_path(folder):
                continue

            full_path = validate_path(resolve_path(folder))
            
            if os.path.exists(full_path):
                ui.print(f"[bold red]>> Error: Folder '{folder}' already exists. Creation aborted.[/bold red]")
                continue
                
            os.makedirs(full_path, exist_ok=True)
            ui.print(f"[bold green]>> Created Directory: {folder}[/bold green]")
            folder_created = True

        except PermissionError as e:
            ui.print(f"[bold red]{e}[/bold red]")
        except Exception as e:
            ui.print(f"[red]>> Failed to create directory {folder}: {e}[/red]")

    if folder_created:
        prompts.clear_file_tree_cache()

    # 1.5) LINE PATCH (Highest Robustness)
    patch_pattern = r"\[PATCH:\s*(.*?),\s*lines\s*(\d+)-(\d+)\]"
    patch_segments = re.split(patch_pattern, text, flags=re.IGNORECASE)
    if len(patch_segments) >= 4:
        for i in range(1, len(patch_segments), 4):
            fname = patch_segments[i].strip()
            start_l = int(patch_segments[i+1])
            end_l = int(patch_segments[i+2])
            content = patch_segments[i+3]
            
            # Clean up content by finding the first code block
            from nova_cli.local.utils import extract_code_from_markdown
            clean_code = extract_code_from_markdown(content) or content
            
            from nova_cli.local.file_manager.edit_ops import apply_line_patch
            target_path = resolve_path(fname)
            
            if apply_line_patch(target_path, start_l, end_l, clean_code):
                if target_path not in modified_files:
                    modified_files.append(target_path)

    # 2) DELETE
    delete_matches = re.findall(r"(?:\*\*|__)?\[DELETE:\s*(.*?)\s*\](?:\*\*|__)?", text, re.IGNORECASE)
    for target in delete_matches:
        target = target.strip()
        if is_placeholder_path(target):
            continue
        # Pass the deletion record back to the shell state manager
        if safe_delete(target):
            modified_files.append(f"DELETED:{target}")

    # 3) EDIT
    edit_split_pattern = r"(?:\*\*|__)?\[EDIT:\s*(.*?)\s*\](?:\*\*|__)?"
    edit_segments = re.split(edit_split_pattern, text, flags=re.IGNORECASE)

    if len(edit_segments) >= 3:
        for i in range(1, len(edit_segments), 2):
            raw_name = edit_segments[i].strip().replace('"', "").replace("'", "").replace("\\", "/")
            
            # Automated Discovery: Resolve path via Global Map if not absolute
            filename = resolve_path(raw_name)
            
            if is_placeholder_path(filename):
                continue

            # JIT Loading: If file exists but isn't active, notify user of discovery
            if os.path.exists(filename) and not os.path.isabs(raw_name):
                try:
                    display_name = os.path.relpath(filename).replace("\\", "/")
                except Exception:
                    display_name = filename
                ui.print(f"[dim]>> Automated Discovery: Resolved {raw_name} to {display_name}[/dim]")

            following_text = edit_segments[i + 1]

            block_pattern = r"<{4,10}(?:\s*SEARCH)?\s*\n?(.*?)\n?={4,10}\s*\n?(.*?)\n?>{4,10}(?:\s*REPLACE)?"
            blocks = re.findall(block_pattern, following_text, re.DOTALL)

            if not blocks:
                ui.print(f"[yellow]>> [EDIT:{filename}] called but no valid SEARCH/REPLACE blocks found.[/yellow]")
                errors.append(f"EDIT_FAILED_NO_BLOCKS file={filename}")
                continue

            edits_made = False

            for search_block, replace_block in blocks:
                search_clean = _normalize_text(_strip_code_fences(search_block))
                replace_clean = _normalize_text(_strip_code_fences(replace_block))

                if apply_surgical_edit(filename, search_clean, replace_clean):
                    edits_made = True
                    if filename not in modified_files:
                        modified_files.append(filename)
                else:
                    # If one block in a file fails, the rest are likely invalid now
                    errors.append(f"BLOCK_FAILED file={filename}")
                    break 

            if edits_made:
                prompts.clear_file_tree_cache()
                pass

    # 4) CREATE
    files_to_create = parse_multiple_files(text)

    if files_to_create:
        for filename, code in files_to_create:
            if cwd and not os.path.isabs(filename):
                filename = os.path.abspath(os.path.join(cwd, filename))
            if is_placeholder_path(filename):
                continue

            # CRITICAL: Strip any code fences that the AI might have nested
            code = _strip_code_fences(code)

            if safe_write(filename, code):
                modified_files.append(filename)
                prompts.clear_file_tree_cache()
    else:
        if "[CREATE:" in text and "[EDIT:" not in text:
            if not any(is_placeholder_path(m) for m in re.findall(r"\[CREATE:\s*(.*?)\s*\]", text)):
                ui.print("[yellow]>> Commands found but parsing failed. Ensure code blocks follow [CREATE] tags.[/yellow]")
                errors.append("CREATE_FAILED_PARSE_MULTIFILE")

    # 5) SHELL (Dependency Fixer)
    shell_matches = re.findall(r"\[SHELL:\s*(.*?)\s*\]", text, re.IGNORECASE)
    for shell_cmd in shell_matches:
        shell_cmd = shell_cmd.strip()
        if "install" in shell_cmd.lower():
            try:
                # Extract package names intelligently
                cmd_parts = shlex.split(shell_cmd)
                
                if "pip" in shell_cmd.lower():
                    packages = [p for p in cmd_parts if p not in ["pip", "install", "-m", "python"]]
                    ui.print(f"[bold cyan]>> Auto-Installing Python Packages: {' '.join(packages)}[/bold cyan]")
                    subprocess.check_call([sys.executable, "-m", "pip", "install"] + packages)
                elif "npm" in shell_cmd.lower():
                    # Preserve all parts after 'npm install'
                        pkg_start_idx = 2 if len(cmd_parts) > 1 and cmd_parts[1] == "install" else 1
                        packages = cmd_parts[pkg_start_idx:]
                        # Filter out legacy/force flags if AI already included it to avoid duplicates
                        packages = [p for p in packages if p not in ["--legacy-peer-deps", "--force"]]
                        ui.print(f"[bold cyan]>> Auto-Installing Node Packages: {' '.join(packages)}[/bold cyan]")
                        # Use npm.cmd on Windows for reliable execution
                        npm_bin = "npm.cmd" if os.name == "nt" else "npm"
                        subprocess.check_call([npm_bin, "install", "--force"] + packages)
                elif "rscript" in shell_cmd.lower() or "install.packages" in shell_cmd.lower():
                    # Extract R packages via regex for visual display
                    pkgs = re.findall(r"['\"]([a-zA-Z0-9_\.]+)['\"]", shell_cmd)
                    pkgs = [p for p in pkgs if p not in ['repos', 'https://cloud.r-project.org', 'quiet', 'TRUE'] and not p.startswith('install.')]
                    pkg_display = ' '.join(pkgs) if pkgs else 'dependencies'
                    ui.print(f"[bold cyan]>> Auto-Installing R Packages: {pkg_display}[/bold cyan]")
                    
                    # Bypass shell quoting by writing to a temp file (Cross-Platform)
                    import tempfile
                    r_code = shell_cmd
                    if shell_cmd.lower().startswith("rscript"):
                        # Extract everything inside the quotes after -e
                        match = re.search(r"-e\s+['\"](.*)['\"]", shell_cmd)
                        if match: 
                            r_code = match.group(1)
                            # Ensure quiet installation to prevent log flooding
                            if "install.packages" in r_code and "quiet" not in r_code:
                                r_code = r_code.replace("install.packages(", "install.packages(quiet=TRUE, ")
                            
                            # CRITICAL FIX: Ensure CRAN mirror is always set to prevent 'contrib.url' errors
                            r_code = 'options(repos = c(CRAN = "https://cloud.r-project.org")); ' + r_code
                        
                    with tempfile.NamedTemporaryFile(suffix=".R", delete=False, mode='w', encoding='utf-8') as tmp:
                        tmp.write(r_code)
                        tmp_path = tmp.name
                        
                    # Resolve absolute path to Rscript to prevent [WinError 2] on Windows
                    from nova_cli.local.utils import ensure_system_tool
                    r_bin = ensure_system_tool("Rscript") or "Rscript"
                    
                    try:
                        subprocess.check_call([r_bin, tmp_path])
                    finally:
                        try: os.remove(tmp_path)
                        except Exception: pass
                else:
                    ui.print(f"[bold cyan]>> Executing Dependency Command: {shell_cmd}[/bold cyan]")
                    subprocess.check_call(shell_cmd, shell=True)
                
                ui.print("[bold green]>> Environment updated successfully.[/bold green]")
                modified_files.append("SYSTEM_ENVIRONMENT")
            except Exception as e:
                ui.print(f"[red]>> Installation failed: {e}[/red]", soft_wrap=True)
                errors.append(f"SHELL_INSTALL_FAILED err={str(e)}")
        else:
            ui.print("[red]>> Blocked: Only dependency installation commands are permitted.[/red]", soft_wrap=True)
            errors.append(f"SHELL_BLOCKED cmd={shell_cmd}")
    return modified_files

--- FILE: local/file_manager/edit_ops.py ---

import os
from nova_cli.local.utils import check_syntax
from nova_cli.local.file_manager.path_ops import resolve_path, validate_path

def normalize_line(line: str) -> str:
    """Removes all whitespace, quotes, and backticks to create a robust comparison fingerprint."""
    # Strip whitespace, lower case, and remove backticks/quotes which AI often hallucinations
    clean = "".join(line.split()).lower()
    return clean.replace("`", "").replace("'", "").replace('"', "")


def fuzzy_replace(content: str, search_block: str, replace_block: str, strict: bool = True) -> tuple[bool, str]:
    """
    Advanced fuzzy matcher that ignores leading/trailing whitespace and empty lines.
    If strict=False, it aggressively strips ALL internal whitespace and case differences.
    It finds the logical match and applies the replacement while attempting to
    preserve the original file's relative indentation.
    """
    import re
    content_lines = content.splitlines()
    # Filter out empty lines from search to handle AI omissions
    search_lines_raw = [l for l in search_block.strip().splitlines() if l.strip()]
    if not search_lines_raw:
        return False, content
    
    if strict:
        search_lines_norm = [l.strip() for l in search_lines_raw]
    else:
        # Aggressively remove all spaces, tabs, quotes, and standardize to lowercase
        search_lines_norm = [normalize_line(l) for l in search_lines_raw]
    
    # We create a map of content lines that are NOT empty
    content_map = [] # list of (original_index, normalized_text)
    for idx, line in enumerate(content_lines):
        if line.strip():
            if strict:
                content_map.append((idx, line.strip()))
            else:
                content_map.append((idx, normalize_line(line)))
            
    search_len = len(search_lines_norm)
    match_start_in_map = -1
    
    for i in range(len(content_map) - search_len + 1):
        # Compare normalized segments
        if [pair[1] for pair in content_map[i : i + search_len]] == search_lines_norm:
            match_start_in_map = i
            break
            
    if match_start_in_map == -1:
        return False, content

    # Get the actual line indices in the original file
    start_line_idx = content_map[match_start_in_map][0]
    end_line_idx = content_map[match_start_in_map + search_len - 1][0]
    
    # Capture original indentation from the first matched line
    first_line = content_lines[start_line_idx]
    indentation = first_line[:len(first_line) - len(first_line.lstrip())]
    
    # Prepare replacement with correct relative indentation
    replace_lines_raw = replace_block.splitlines()
    replacement_lines = []
    if replace_lines_raw:
        first_replace_line = next((l for l in replace_lines_raw if l.strip()), "")
        ai_indentation = first_replace_line[:len(first_replace_line) - len(first_replace_line.lstrip())]
        
        for l in replace_lines_raw:
            if not l.strip():
                replacement_lines.append(l)
            else:
                if l.startswith(ai_indentation):
                    replacement_lines.append(indentation + l[len(ai_indentation):])
                else:
                    replacement_lines.append(indentation + l.lstrip())
    
    new_lines = content_lines[:start_line_idx] + replacement_lines + content_lines[end_line_idx + 1:]
    return True, "\n".join(new_lines) + "\n"


def apply_surgical_edit(filepath: str, search_block: str, replace_block: str) -> bool:
    """
    Reads file, finds search_block, replaces with replace_block.
    Supports exact match and fuzzy match (whitespace insensitive).
    Performs syntax check before saving.
    """
    # LOCAL IMPORTS to break circular dependency
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes

    full_path = filepath 
    
    if not os.path.isabs(full_path):
        try:
            full_path = validate_path(resolve_path(filepath))
        except Exception:
            full_path = os.path.abspath(filepath)

    if not os.path.exists(full_path):
        ui.print(f"[red]>> Edit Failed: File not found {filepath}[/red]")
        return False

    with open(full_path, "r", encoding="utf-8") as f:
        content = f.read()

    search_block_norm = search_block.replace("\r\n", "\n")
    content_norm = content.replace("\r\n", "\n")

    new_content = None
    method_used = ""

    # Normalize internal line endings and trailing whitespace for a fairer comparison
    search_stripped = "\n".join([l.rstrip() for l in search_block_norm.strip().splitlines()])
    content_stripped = "\n".join([l.rstrip() for l in content_norm.splitlines()])

    # Pass 1: Try Exact Match (Fastest)
    if search_block_norm.strip() in content_norm:
        new_content = content_norm.replace(search_block_norm.strip(), replace_block.strip())
        method_used = "Exact Match"
    
    # Pass 2: Line-by-Line Normalization Match (Resilient to trailing spaces)
    if not new_content:
        search_lines = [l.rstrip() for l in search_block_norm.strip().splitlines()]
        content_lines = [l.rstrip() for l in content_norm.splitlines()]
        
        for i in range(len(content_lines) - len(search_lines) + 1):
            if content_lines[i : i + len(search_lines)] == search_lines:
                orig_lines = content_norm.splitlines()
                reconstructed = orig_lines[:i] + replace_block.strip().splitlines() + orig_lines[i + len(search_lines):]
                new_content = "\n".join(reconstructed) + "\n"
                method_used = "Line-Normalized Match"
                break

    # Pass 3: Indentation-Insensitive Fuzzy Match
    if not new_content:
        success, fuzzy_content = fuzzy_replace(content_norm, search_block_norm, replace_block, strict=True)
        if success:
            new_content = fuzzy_content
            method_used = "Indentation-Insensitive Match"

    try:
        display_name = os.path.relpath(filepath).replace("\\", "/")
    except Exception:
        display_name = os.path.basename(filepath)

    if not new_content:
        # Pass 4: Ultra-Robust Fuzzy Match (Ignores internal spaces & case)
        success, fuzzy_content = fuzzy_replace(content_norm, search_block_norm, replace_block.strip(), strict=False)
        if success:
            new_content = fuzzy_content
            method_used = "Ultra-Robust Match"
        else:
            ui.print(f"[red]>> Edit Failed: SEARCH block not found in {display_name}[/red]")
            ui.print("[dim]   (Tip: The AI might have hallucinated indentation or spacing. Check file content.)[/dim]")
            return False

    is_valid, syntax_error = check_syntax(new_content, full_path)
    if not is_valid:
        ui.print("[bold red]>> Edit Rejected: Resulting code has Syntax Error.[/bold red]")
        ui.print(f"[red]>> {syntax_error}[/red]")
        return False

    with open(full_path, "w", encoding="utf-8") as f:
        f.write(new_content)

    ui.print(f"[green]>> Surgical Edit Applied ({method_used}): {display_name}[/green]")
        
    # Force Git sync for discovered files to maintain repo integrity
    from nova_cli.local.file_manager.git_ops import commit_changes
    commit_changes(f"Surgical edit: {display_name}", full_path, use_semantic=True)
    return True


def apply_line_patch(filepath: str, start_line: int, end_line: int, new_content: str) -> bool:
    """Directly replaces a range of lines by index. The most robust patching method."""
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes

    if not os.path.exists(filepath):
        return False

    with open(filepath, "r", encoding="utf-8") as f:
        lines = f.readlines()

    # Convert 1-based AI indices to 0-based Python indices
    start_idx = max(0, start_line - 1)
    end_idx = min(len(lines), end_line)

    new_lines = new_content.splitlines(keepends=True)
    # Ensure the last line has a newline if the original did
    if new_lines and not new_lines[-1].endswith("\n"):
        new_lines[-1] += "\n"

    updated_lines = lines[:start_idx] + new_lines + lines[end_idx:]

    with open(filepath, "w", encoding="utf-8") as f:
        f.writelines(updated_lines)

    display_name = os.path.basename(filepath)
    ui.print(f"[bold green]>> Line Patch Applied: {display_name} (Lines {start_line}-{end_line})[/bold green]")
    commit_changes(f"Line patch: {display_name}", filepath, use_semantic=True)
    return True

--- FILE: local/file_manager/git_ops.py ---

# NOVA_CLI/nova_cli/local/file_manager/git_ops.py

import os
from pathlib import Path

import git

from nova_cli import config

from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient

# --- GIT INTEGRATION ---
repo_path: Path = Path(os.getcwd())
repo: git.Repo | None = None


def _default_commit_message() -> str:
    return "chore: update files"

def get_repo():
    """Lazily load the repo only when needed."""
    global repo
    try:
        # Check if current dir or any parent is a git repo
        repo = git.Repo(os.getcwd(), search_parent_directories=True)
        return repo
    except (git.exc.InvalidGitRepositoryError, git.exc.NoSuchPathError):
        repo = None
        return None

def _get_origin_url(r) -> str | None:
    try:
        if "origin" in r.remotes:
            return next(r.remotes.origin.urls, None)
    except Exception:
        pass
    return None
    
def initialize_repo() -> bool:
    from nova_cli.local.ui import ui
    global repo
    try:
        repo = git.Repo.init(os.getcwd())
        
        # PRO MOVE: Create a default .gitignore if it doesn't exist
        gitignore_path = os.path.join(os.getcwd(), ".gitignore")
        if not os.path.exists(gitignore_path):
            with open(gitignore_path, "w") as f:
                f.write(".nova_history\n__pycache__/\n*.pyc\n.env\n")
        
        ui.print("[green]✔ Git repository initialized with default .gitignore.[/green]")
        return True
    except Exception as e:
        ui.print(f"[red]Failed to initialize Git: {e}[/red]")
        return False

def update_repo_path(new_path: str) -> None:
    """Updates the repo object when user CDs without forcing init."""
    global repo, repo_path
    repo_path = Path(new_path)
    repo = get_repo()

def commit_changes(message: str, filepath: str | None = None, use_semantic: bool = False) -> bool:
    from nova_cli.local.ui import ui
    r = get_repo()
    
    # 1. Respect Auto-Commit Toggle
    if not r or not config.GIT_AUTO_COMMIT:
        return False

    try:
        # 2. Stage changes
        if filepath and os.path.exists(filepath):
            r.git.add(filepath)
        else:
            # Stage all modified and deleted files
            r.git.add(update=True)

        # 3. Check for staged changes (Robust check for initial & existing repos)
        has_staged_changes = False
        try:
            # If HEAD exists, diff against it
            if r.index.diff("HEAD"):
                has_staged_changes = True
        except git.exc.BadName:
            # HEAD doesn't exist (Initial Commit) - check if index is not empty
            if len(r.index.entries) > 0:
                has_staged_changes = True

        if has_staged_changes:
            final_msg = f"NOVA: {message}"
            if use_semantic and filepath:
                final_msg = f"chore: update {os.path.basename(filepath)}"

            r.index.commit(final_msg)
            ui.print(f"[dim]>> Auto-Commit: {final_msg}[/dim]")

            # 4. Respect Auto-Push Toggle (Chained)
            if config.GIT_AUTO_PUSH:
                perform_push()
            return True

    except Exception as e:
        ui.print(f"[yellow]>> Git Auto-Commit Error: {e}[/yellow]")

    return False


def manual_commit(model: str | None = None, provider: str | None = None) -> bool:
    """Forces a commit with an option for AI generation or manual input."""
    from nova_cli.local.ui import ui
    import questionary
    
    r = get_repo()
    if not r:
        ui.print("[yellow]>> Not a git repository. Use ':gitoptions' to initialize.[/yellow]")
        return False

    try:
        r.git.add(".")
        # Robust check for changes
        has_changes = False
        try:
            if r.is_dirty(untracked_files=True) or r.index.diff("HEAD"):
                has_changes = True
        except git.exc.BadName:
            if len(r.index.entries) > 0:
                has_changes = True

        if not has_changes:
            ui.print("[dim]>> Nothing to commit (clean working tree).[/dim]")
            return False

        ui.print("[dim]>> Detected changes to commit.[/dim]")
        choice = questionary.select(
            "Choose commit message option:",
            choices=[
                "🤖 Auto-generate commit message",
                "✍️  Enter manually",
            ],
        ).ask()

        if not choice:
            ui.print("[red]>> Commit cancelled.[/red]")
            return False

        commit_msg = None
        if "Auto-generate" in choice:
            try:
                ui.print("[dim cyan]>> Generating commit message...[/dim cyan]")
                diff = r.git.diff(cached=True) or r.git.diff() # Check staged then unstaged

                client = BridgeyeAPIClient()
                commit_msg = client.generate_commit_message(
                    diff_text=diff,
                    model=model or config.DEFAULT_MODEL,
                    provider=provider or "openrouter",
                )
                if not commit_msg.strip():
                    commit_msg = _default_commit_message()
                ui.print(f"[dim]>> AI Commit: {commit_msg}[/dim]")
            except Exception as e:
                ui.print(f"[yellow]>> AI generation failed: {e}[/yellow]")
                commit_msg = _default_commit_message()
        else:
            commit_msg = ui.input("[cyan]Enter commit message:[/cyan] ").strip()
            if not commit_msg:
                commit_msg = _default_commit_message()

        r.index.commit(f"NOVA: {commit_msg}")
        ui.print(f"[green]>> Commit Success: {commit_msg}[/green]")
        return True

    except Exception as e:
        ui.print(f"[red]>> Commit Failed: {e}[/red]")
        return False


def perform_push(model: str | None = None, provider: str | None = None) -> None:
    from nova_cli.local.ui import ui
    import questionary

    r = get_repo()
    if not r:
        ui.print("[yellow]>> No repository found to push.[/yellow]")
        return

    if not ensure_git_identity(r):
        return

    try:
        # ----------------------------
        # STEP 1: Ensure changes are committed
        # ----------------------------
        manual_commit(model=model, provider=provider)

        # ----------------------------
        # STEP 2: Ensure remote exists
        # ----------------------------
        if not ensure_remote_origin(r):
            return

        # ----------------------------
        # STEP 3: Push
        # ----------------------------
        ui.print("[dim]>> Pushing to origin...[/dim]")
        try:
            branch = r.active_branch.name
            r.git.push("--set-upstream", "origin", branch)
        except Exception:
            r.remotes.origin.push()

        ui.print("[green]>> Push Complete.[/green]")
        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]>> Remote:[/dim] {origin_url}")

    except Exception as e:
        ui.print(f"[red]>> Push Failed: {e}[/red]")


def perform_pull() -> None:
    from nova_cli.local.ui import ui
    import questionary

    r = get_repo()

    if not r:
        choice = questionary.select(
            "This folder is not a git repository. What would you like to do?",
            choices=[
                "📦 Initialize repository and continue",
                "❌ Cancel",
            ],
        ).ask()

        if not choice or "Cancel" in choice:
            ui.print("[red]>> Pull cancelled.[/red]")
            return

        if not initialize_repo():
            return

        r = get_repo()
        if not r:
            ui.print("[red]>> Failed to initialize repository.[/red]")
            return

    try:
        # 1. Ensure remote exists first
        if not ensure_remote_for_pull(r):
            return

        # 2. Fetch first
        ui.print("[dim]>> Fetching from origin...[/dim]")
        r.remotes.origin.fetch()

        # 3. Warn only now, right before actual pull/checkout
        if r.is_dirty(untracked_files=True):
            ui.print("[yellow]>> You have local changes. Pull may cause conflicts.[/yellow]")

            choice = questionary.select(
                "How would you like to continue?",
                choices=[
                    "⬇️ Continue pull",
                    "🧨 Force sync with origin",
                    "❌ Cancel",
                ],
            ).ask()

            if not choice or "Cancel" in choice:
                ui.print("[red]>> Pull cancelled.[/red]")
                return

            if "Force sync" in choice:
                force_sync_with_origin()
                return

        # 4. Normal pull path
        try:
            branch = r.active_branch.name
            ui.print(f"[dim]>> Pulling branch '{branch}' from origin...[/dim]")
            r.git.pull("origin", branch)
            ui.print("[green]>> Pull Complete.[/green]")

            origin_url = _get_origin_url(r)
            if origin_url:
                ui.print(f"[dim]>> Remote:[/dim] {origin_url}")
            return

        except Exception:
            pass

        # 5. Fallback for fresh repos with no checked-out branch yet
        remote_head = None
        try:
            remote_head = r.git.symbolic_ref("refs/remotes/origin/HEAD")
            remote_head = remote_head.split("/")[-1].strip()
        except Exception:
            pass

        if not remote_head:
            for candidate in ("main", "master"):
                try:
                    r.git.rev_parse(f"origin/{candidate}")
                    remote_head = candidate
                    break
                except Exception:
                    continue

        if not remote_head:
            ui.print("[red]>> Could not determine remote default branch.[/red]")
            return

        ui.print(f"[dim]>> Checking out '{remote_head}' from origin...[/dim]")
        try:
            r.git.checkout("-b", remote_head, f"origin/{remote_head}")
        except Exception:
            r.git.checkout(remote_head)

        ui.print("[green]>> Pull Complete.[/green]")

        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]>> Remote:[/dim] {origin_url}")

    except Exception as e:
        ui.print(f"[red]>> Pull Failed: {e}")


def git_status() -> None:
    from nova_cli.local.ui import ui
    r = get_repo()
    if not r:
        ui.print("[dim]>> Not a git repository.[/dim]")
        return
    try:
        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]Remote:[/dim] {origin_url}")

        if r.is_dirty(untracked_files=True):
            ui.print(f"[yellow]{r.git.status()}[/yellow]")
        else:
            ui.print("[green]Git Status: Clean working tree.[/green]")
    except Exception as e:
        ui.print(f"[red]{e}[/red]")

def ensure_git_identity(r) -> bool:
    from nova_cli.local.ui import ui

    try:
        name = r.config_reader().get_value("user", "name")
        email = r.config_reader().get_value("user", "email")
        if name and email:
            return True
    except Exception:
        pass

    ui.print("[yellow]>> Git user identity not configured.[/yellow]")

    name = ui.input("[cyan]Enter your Git username:[/cyan] ").strip()
    email = ui.input("[cyan]Enter your Git email:[/cyan] ").strip()

    if not name or not email:
        ui.print("[red]>> Git identity setup cancelled.[/red]")
        return False

    r.config_writer().set_value("user", "name", name).release()
    r.config_writer().set_value("user", "email", email).release()

    ui.print("[green]>> Git identity configured.[/green]")
    return True
    
def ensure_remote_origin(r) -> bool:
    from nova_cli.local.ui import ui
    import questionary

    if "origin" in r.remotes:
        return True

    ui.print("[yellow]>> No remote 'origin' found.[/yellow]")

    choice = questionary.select(
        "How would you like to set up remote?",
        choices=[
            "🔗 Use existing repository URL",
            "🆕 Create new GitHub repository (recommended)",
        ],
    ).ask()

    if not choice:
        ui.print("[red]>> Remote setup cancelled.[/red]")
        return False

    if "existing" in choice:
        repo_url = ui.input("[cyan]Enter remote repository URL:[/cyan] ").strip()
        if not repo_url:
            ui.print("[red]>> Cancelled.[/red]")
            return False

    else:
        repo_name = ui.input("[cyan]Enter new repository name:[/cyan] ").strip()
        if not repo_name:
            ui.print("[red]>> Cancelled.[/red]")
            return False

        is_private = questionary.select(
            "Choose repository visibility:",
            choices=[
                "🔒 Private",
                "🌍 Public",
            ],
            default="🔒 Private",
        ).ask()

        if not is_private:
            ui.print("[red]>> Repository creation cancelled.[/red]")
            return False

        private_flag = is_private.startswith("🔒")

        ui.print("[dim]>> Creating GitHub repository...[/dim]")

        try:
            client = BridgeyeAPIClient()
            result = client.create_github_repo(
                repo_name=repo_name,
                private=private_flag,
                description="Created via NOVA CLI",
            )

            repo_url = (result.get("clone_url") or "").strip()
            if not repo_url:
                raise RuntimeError("GitHub repo created but clone_url missing")

            visibility = "private" if private_flag else "public"
            ui.print(f"[green]>> GitHub {visibility} repo created: {result.get('html_url')}[/green]")

        except Exception as e:
            msg = str(e)
            if "already exists" in msg.lower() or "name already exists" in msg.lower():
                ui.print("[red]>> Repository with this name already exists on GitHub. Try another name.[/red]")
            else:
                ui.print(f"[red]>> Failed to create GitHub repo: {e}[/red]")
            return False
    

    try:
        r.create_remote("origin", repo_url)
        ui.print(f"[green]>> Remote added: {repo_url}[/green]")
        return True
    except Exception as e:
        ui.print(f"[red]>> Failed to add remote: {e}[/red]")
        return False

def ensure_remote_for_pull(r) -> bool:
    from nova_cli.local.ui import ui

    if "origin" in r.remotes:
        return True

    ui.print("[yellow]>> No remote 'origin' found.[/yellow]")
    repo_url = ui.input("[cyan]Enter existing repository URL to pull from:[/cyan] ").strip()

    if not repo_url:
        ui.print("[red]>> Pull cancelled.[/red]")
        return False

    try:
        r.create_remote("origin", repo_url)
        ui.print(f"[green]>> Remote added: {repo_url}[/green]")
        return True
    except Exception as e:
        ui.print(f"[red]>> Failed to add remote: {e}[/red]")
        return False 

def force_sync_with_origin() -> None:
    from nova_cli.local.ui import ui
    import questionary

    r = get_repo()
    if not r:
        ui.print("[yellow]>> Not a git repository.[/yellow]")
        return

    if not ensure_remote_for_pull(r):
        return

    confirm = questionary.confirm(
        "This will discard local changes and hard reset to the remote branch. Continue?",
        default=False,
    ).ask()

    if not confirm:
        ui.print("[red]>> Force sync cancelled.[/red]")
        return

    try:
        ui.print("[dim]>> Fetching from origin...[/dim]")
        r.remotes.origin.fetch()

        branch = None
        try:
            branch = r.active_branch.name
        except Exception:
            pass

        if not branch:
            for candidate in ("main", "master"):
                try:
                    r.git.rev_parse(f"origin/{candidate}")
                    branch = candidate
                    break
                except Exception:
                    continue

        if not branch:
            ui.print("[red]>> Could not determine remote branch for force sync.[/red]")
            return

        ui.print(f"[dim]>> Resetting hard to origin/{branch}...[/dim]")
        r.git.reset("--hard", f"origin/{branch}")
        ui.print("[green]>> Force sync complete.[/green]")

        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]>> Remote:[/dim] {origin_url}")

    except Exception as e:
        ui.print(f"[red]>> Force sync failed: {e}[/red]")       

--- FILE: local/file_manager/io_ops.py ---

import os
import shutil
import difflib
import datetime

from rich.tree import Tree
from rich.panel import Panel
from rich.prompt import Confirm
from rich.syntax import Syntax
from nova_cli import config

# Moved ui import inside functions
import core.prompts as prompts  # keep as-is for now
from nova_cli.local.utils import check_syntax

from nova_cli.local.file_manager.path_ops import resolve_path, validate_path
# Moved commit_changes import inside functions


def create_backup(filepath: str) -> None:
    """Moves existing file to .nova/backups with timestamp."""
    from nova_cli.local.ui import ui
    try:
        if not os.path.exists(filepath):
            return

        root = os.path.abspath(os.getcwd())
        backup_root = os.path.join(root, ".nova", "backups")

        rel_path = os.path.relpath(filepath, root)
        dest_base = os.path.join(backup_root, rel_path)
        dest_dir = os.path.dirname(dest_base)
        os.makedirs(dest_dir, exist_ok=True)

        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        dest_path = f"{dest_base}.{timestamp}.bak"

        shutil.copy2(filepath, dest_path)
        ui.print(f"[dim]>> Backup archived: .nova/backups/{rel_path}.{timestamp}.bak[/dim]")
    except Exception as e:
        ui.print(f"[yellow]>> Backup Warning: Could not create backup for {filepath}: {e}[/yellow]")


def map_directory(path: str = ".") -> None:
    """Visualizes file structure."""
    from nova_cli.local.ui import ui
    tree = Tree(f"[bold cyan]{os.path.basename(os.path.abspath(path))}[/bold cyan]")
    try:
        paths = sorted(
            os.listdir(path),
            key=lambda x: (not os.path.isdir(os.path.join(path, x)), x.lower()),
        )
        for entry in paths:
            if entry.startswith(".") or entry == "__pycache__":
                continue

            full_path = os.path.join(path, entry)
            if os.path.isdir(full_path):
                branch = tree.add(f"[bold yellow]📂 {entry}[/bold yellow]")
                try:
                    sub_paths = sorted(os.listdir(full_path))
                    for sub in sub_paths:
                        if sub.startswith(".") or sub == "__pycache__":
                            continue
                        sub_full = os.path.join(full_path, sub)
                        if os.path.isdir(sub_full):
                            branch.add(f"[bold yellow]📂 {sub}[/bold yellow]")
                        else:
                            branch.add(f"[dim]📄 {sub}[/dim]")
                except PermissionError:
                    branch.add("[red]ACCESS DENIED[/red]")
            else:
                tree.add(f"📄 {entry}")
    except Exception as e:
        tree.add(f"[red]Error: {e}[/red]")

    ui.print(Panel(tree, title="Directory Map", border_style="cyan"))


def get_project_files(extension: str = ".py") -> list[str]:
    """Scans the project for files with extension, excluding ignored dirs."""
    excluded = {
        ".git",
        "__pycache__",
        "venv",
        "env",
        "node_modules",
        ".idea",
        ".vscode",
        "dist",
        "build",
        ".next",
        ".ds_store",
        ".mypy_cache",
        ".nova",
    }

    source_files: list[str] = []
    start_path = os.getcwd()

    for root, dirs, files in os.walk(start_path):
        dirs[:] = [d for d in dirs if d not in excluded and not d.startswith(".")]

        for file in files:
            if file.endswith(extension):
                full_path = os.path.join(root, file)
                source_files.append(os.path.relpath(full_path, start_path))

    return sorted(source_files)


def run_creation_wizard() -> None:
    from nova_cli.local.ui import ui
    ui.print(Panel("[bold yellow]creation_wizard.exe initialized[/bold yellow]", border_style="yellow"))

    folder_name = ui.input("[cyan]1. Target Folder Name > [/cyan]").strip()
    if not folder_name:
        return

    files_input = ui.input("[cyan]2. File Names (space separated) > [/cyan]").strip()
    if not files_input:
        return
    file_names = files_input.split()

    extension = ui.input("[cyan]3. Extension (e.g. .py) > [/cyan]").strip()
    if not extension.startswith("."):
        extension = "." + extension

    try:
        target_dir = validate_path(os.path.join(os.getcwd(), folder_name))
        os.makedirs(target_dir, exist_ok=True)
        ui.print(f"[green]>> Folder created: {folder_name}[/green]")

        for name in file_names:
            fname = f"{name}{extension}"
            fpath = validate_path(os.path.join(target_dir, fname))

            if not os.path.exists(fpath):
                with open(fpath, "w", encoding="utf-8") as f:
                    f.write(f"# File: {fname}\n")
                ui.print(f"   [dim]Created: {fname}[/dim]")
            else:
                ui.print(f"   [yellow]Skipped: {fname}[/yellow]")

        prompts.clear_file_tree_cache()

    except Exception as e:
        ui.print(f"[bold red]>> ERROR: {e}[/bold red]")


def show_diff(filepath: str, new_content: str) -> None:
    """Displays diff between existing file and new content."""
    from nova_cli.local.ui import ui
    if not os.path.exists(filepath) or os.path.isdir(filepath):
        ui.print("[dim]>> New File (No diff available)[/dim]")
        return

    with open(filepath, "r", encoding="utf-8") as f:
        old_lines = f.readlines()

    new_lines = new_content.splitlines(keepends=True)

    diff = list(
        difflib.unified_diff(
            old_lines,
            new_lines,
            fromfile=f"original/{os.path.basename(filepath)}",
            tofile=f"proposed/{os.path.basename(filepath)}",
        )
    )

    if not diff:
        ui.print("[dim]>> Content is identical.[/dim]")
        return

    diff_text = "".join(diff)
    syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=True)
    ui.print(Panel(syntax, title=f"Changes: {os.path.basename(filepath)}", border_style="yellow"))


def safe_delete(filepath: str) -> bool:
    """Unified delete with security and confirmation."""
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes
    try:
        full_path = validate_path(filepath)
        if not os.path.exists(full_path):
            ui.print(f"[yellow]>> Delete skipped: {filepath} not found.[/yellow]")
            return False

        ui.print(Panel(f"Target: [bold red]{full_path}[/bold red]", title="DELETE CONFIRMATION", style="red"))
        
        # Overdrive bypass
        if config.OVERDRIVE:
            ui.print(f"[bold yellow]>> Overdrive: Auto-confirming deletion of {os.path.basename(full_path)}[/bold yellow]")
            should_delete = True
        else:
            should_delete = Confirm.ask(f">> DELETE {os.path.basename(full_path)} permanently?")

        if should_delete:
            if os.path.isdir(full_path):
                shutil.rmtree(full_path)
            else:
                os.remove(full_path)

            ui.print(f"[bold red]>> DELETED: {os.path.basename(full_path)}[/bold red]")
            prompts.clear_file_tree_cache()
            commit_changes(f"Deleted {os.path.basename(full_path)}", full_path)
            return True
        else:
            ui.print("[dim]>> Delete cancelled.[/dim]")
            return False

    except Exception as e:
        ui.print(f"[bold red]>> DELETE ERROR: {e}[/bold red]")
        return False


def safe_rename(old_path: str, new_path: str) -> bool:
    """Renames a file or folder with boundary security and Git tracking."""
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes
    try:
        full_old = validate_path(resolve_path(old_path))
        
        # Determine the target path (relative to the source's directory if not absolute)
        if os.path.isabs(new_path):
            full_new = validate_path(new_path)
        else:
            full_new = validate_path(os.path.join(os.path.dirname(full_old), new_path))

        if not os.path.exists(full_old):
            ui.print(f"[red]>> Rename Failed: Source '{old_path}' not found.[/red]")
            return False

        if os.path.exists(full_new):
            ui.print(f"[red]>> Rename Failed: Target '{new_path}' already exists.[/red]")
            return False

        os.rename(full_old, full_new)
        
        old_name = os.path.basename(full_old)
        new_name = os.path.basename(full_new)
        ui.print(f"[green]>> Renamed: {old_name} -> {new_name}[/green]")
        
        # Track in Git and clear map cache
        commit_changes(f"Renamed {old_name} to {new_name}")
        prompts.clear_file_tree_cache()
        return True

    except Exception as e:
        ui.print(f"[bold red]>> RENAME ERROR: {e}[/bold red]")
        return False


def safe_write(filepath: str, content: str) -> bool:
    """
    Unified write enforcing:
    security, syntax checking, diff preview, confirmation, backup, semantic commit.
    """
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes
    try:
        full_path = validate_path(resolve_path(filepath))
    except PermissionError as e:
        ui.print(f"[bold red]{e}[/bold red]")
        return False

    if os.path.isdir(full_path):
        ui.print(f"[bold red]>> Error: Target path '{filepath}' is an existing directory. Cannot write file.[/bold red]")
        return False

    is_valid, syntax_err = check_syntax(content, full_path)
    if not is_valid:
        ui.print(f"[bold red]>> Warning: Code has Syntax Error: {syntax_err}[/bold red]")
        if config.OVERDRIVE:
            ui.print("[bold yellow]>> Overdrive: Auto-confirming force-write of invalid code for auto-healing...[/bold yellow]")
        elif not Confirm.ask(">> Force write invalid code?"):
            return False

    show_diff(full_path, content)

    ui.print(
        Panel(
            f"Target: [bold cyan]{full_path}[/bold cyan]\nBytes: {len(content)}",
            title="WRITE CONFIRMATION",
            style="yellow",
        )
    )

    prompt_msg = (
        f">> OVERWRITE {os.path.basename(full_path)}?"
        if os.path.exists(full_path)
        else f">> CREATE {os.path.basename(full_path)}?"
    )

    # Overdrive Bypass
    if config.OVERDRIVE:
        ui.print(f"[bold yellow]>> Overdrive: Auto-confirming {os.path.basename(full_path)}[/bold yellow]")
    elif not Confirm.ask(prompt_msg):
        ui.print("[dim]>> Cancelled.[/dim]")
        return False

    try:
        directory = os.path.dirname(full_path)
        if directory and not os.path.exists(directory):
            os.makedirs(directory, exist_ok=True)

        tmp_path = f"{full_path}.tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            f.write(content)

        if os.path.exists(full_path):
            create_backup(full_path)

        os.replace(tmp_path, full_path)

        ui.print(f"[bold green]>> SUCCESS: {os.path.basename(full_path)} written.[/bold green]")
        commit_changes(f"Updated {os.path.basename(full_path)}", full_path, use_semantic=True)

        prompts.clear_file_tree_cache()
        return True

    except Exception as e:
        if "tmp_path" in locals() and os.path.exists(tmp_path):
            os.remove(tmp_path)
        ui.display_error(f"Could not save changes: {e}", title="Write Error")
        return False


def save_code_to_file(active_file: str | None, code_content: str | None) -> None:
    """Saves provided code content to active file."""
    from nova_cli.local.ui import ui
    if not active_file:
        ui.print("[red]>> ERROR: No active file loaded.[/red]")
        return

    if not code_content:
        ui.print("[red]>> ERROR: No stored code found to apply.[/red]")
        return

    safe_write(active_file, code_content)


def load_file(filepath: str) -> tuple[str | None, str]:
    from nova_cli.local.ui import ui
    try:
        full_path = validate_path(resolve_path(filepath))

        if not os.path.exists(full_path):
            ui.print(f"[red]>> NOT FOUND: {filepath}[/red]")
            return None, ""

        with open(full_path, "r", encoding="utf-8") as f:
            content = f.read()

        ui.print(f"[dim cyan]>> UPLOADING {len(content)} BYTES...[/dim cyan]")
        return full_path, content

    except PermissionError as e:
        ui.print(f"[bold red]{e}[/bold red]")
        return None, ""
    except Exception as e:
        ui.print(f"[red]>> READ ERROR: {e}[/red]")
        return None, ""

--- FILE: local/file_manager/path_ops.py ---

import os
from nova_cli import config as cli_config
from nova_cli.local.file_manager.git_ops import repo


def validate_path(filepath: str) -> str:
    """
    Security Barrier: Ensures the target path is within the established PROJECT_ROOT.
    Prevents directory traversal attacks.
    """
    from nova_cli import config as cli_config
    
    # Boundary is defined by the current set PROJECT_ROOT
    root_boundary = cli_config.PROJECT_ROOT
    target = os.path.abspath(filepath)

    if os.path.commonpath([root_boundary, target]) != root_boundary:
        raise PermissionError(
            f"Access Denied: Operating outside of restricted root: {root_boundary}"
        )

    return target


def resolve_path(filepath: str) -> str:
    """
    Resolves a filepath. 
    1. Prioritizes the PROJECT_ROOT (Anchor).
    2. If not found in Root, falls back to CWD.
    3. If not found, searches relative to the Git Root.
    4. Defaults to PROJECT_ROOT absolute path for new files.
    """
    # Normalize path separators
    filepath = filepath.replace("\\", "/")
    
    # 1. Prioritize Project Root Anchor
    root_path = os.path.abspath(os.path.join(cli_config.PROJECT_ROOT, filepath))
    if os.path.exists(root_path):
        return root_path

    # 2. Check relative to current working directory (CWD)
    cwd_path = os.path.abspath(filepath)
    if os.path.exists(cwd_path) and os.path.isfile(cwd_path):
        return cwd_path

    # 1.5 Handle AI hallucinating the project root folder name in the path
    cwd_basename = os.path.basename(os.path.abspath(os.getcwd()))
    if filepath.startswith(cwd_basename + "/") or filepath.startswith(cwd_basename + "\\"):
        stripped_path = filepath[len(cwd_basename) + 1:]
        test_path = os.path.abspath(stripped_path)
        if os.path.exists(test_path) and os.path.isfile(test_path):
            return test_path

    # 2. Check relative to Git Root (only if file exists there)
    # This helps find files if the AI uses root-relative paths while we are in a subfolder.
    if repo and repo.working_dir:
        root_path = os.path.abspath(os.path.join(repo.working_dir, filepath))
        if os.path.exists(root_path):
            return root_path

    # 4. Fallback: Return absolute path (defaults to CWD for new file creation)
    return os.path.abspath(filepath)


--- FILE: local/healer/__init__.py ---



--- FILE: local/healer/runner.py ---

# nova_cli/local/healer/runner.py

import os
import re
import time
import subprocess
from typing import Dict, List, Optional
import py_compile
import tempfile

from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.ui import ui
import core.prompts as prompts
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient


def _syntax_check(path: str) -> tuple[bool, str]:
    try:
        py_compile.compile(path, doraise=True)
        return True, ""
    except Exception as e:
        return False, str(e)


def _read_text(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def _write_text(path: str, content: str) -> None:
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)


def _strip_utf8_bom(path: str) -> bool:
    try:
        with open(path, "rb") as f:
            raw = f.read()
        if raw.startswith(b"\xef\xbb\xbf"):
            with open(path, "wb") as f:
                f.write(raw[3:])
            return True
    except Exception:
        return False
    return False


def _ensure_target_file_in_context(command_args: List[str], cwd: str, ctx: Dict[str, str]) -> None:
    """
    Ensure the executed target file is present in context with canonical relative keys.
    """
    if not command_args:
        return

    target = command_args[-1]
    if not (isinstance(target, str) and "." in target):
        return
    
    raw_abs = os.path.abspath(os.path.join(cwd, target))
    if not os.path.exists(raw_abs) or os.path.isdir(raw_abs):
        return

    try:
        with open(raw_abs, "r", encoding="utf-8", errors="replace") as f:
            content = f.read().replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
    except Exception:
        return

    # Prefer relative path to save tokens and enforce AI relative path rules
    try:
        rel = os.path.relpath(raw_abs, cwd).replace("\\", "/")
        ctx[rel] = content
    except Exception:
        drive, rest = os.path.splitdrive(raw_abs)
        abs_path = (drive.upper() + rest).replace("\\", "/")
        ctx[abs_path] = content
        
    # CRITICAL: Always provide the pure basename (e.g. final_model.R) 
    # to guarantee the backend validation engine finds the file when the AI uses it.
    ctx[os.path.basename(raw_abs)] = content

    # Label a SOURCE_OF_TRUTH copy with line numbers so the AI can be surgically precise
    lines = content.splitlines()
    numbered_content = "\n".join([f"{i+1}| {line}" for i, line in enumerate(lines)])
    ctx[f"VERBATIM_SOURCE_WITH_LINES::{os.path.basename(raw_abs)}"] = numbered_content
    ctx[f"VERBATIM_SOURCE::{os.path.basename(raw_abs)}"] = content


def _run_once(command_args: List[str], cwd: str) -> tuple[int, str, str]:
    """Runs the process and streams output to terminal in real-time."""
    stdout_lines = []
    stderr_lines = []
    
    # Use shell=True on Windows for command wrappers (.cmd/.bat)
    # [FIX]: Ensure all command args are strings to prevent NoneType errors
    safe_args = [str(a) for a in command_args if a is not None]
    use_shell = os.name == "nt"
    cmd = " ".join(f'"{a}"' if " " in a else a for a in safe_args) if use_shell else safe_args
    
    try:
        process = subprocess.Popen(
            cmd,
            cwd=cwd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            stdin=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1,
            universal_newlines=True,
            shell=use_shell
        )
    except FileNotFoundError:
        return 127, "", f"Nova Error: The runner executable '{command_args[0]}' could not be found in the system PATH."

    import threading
    def stream_reader(pipe, container):
        for line in iter(pipe.readline, ''):
            if line:
                container.append(line)
                # Raw print removed here to allow Rich UI to handle the display once.
        pipe.close()

    t1 = threading.Thread(target=stream_reader, args=(process.stdout, stdout_lines))
    t2 = threading.Thread(target=stream_reader, args=(process.stderr, stderr_lines))
    
    # Mark as daemon threads so they don't block NOVA from exiting
    t1.daemon = True
    t2.daemon = True
    
    t1.start()
    t2.start()
    
    # Persistence Check: Detect web servers (Vite, npm run dev)
    is_server = any(kw in str(command_args).lower() for kw in ["npm", "run", "dev", "vite", "streamlit"])
    
    if is_server:
        # Give the server 8 seconds to handle pre-bundling and import analysis
        start_wait = time.time()
        while time.time() - start_wait < 8:
            time.sleep(0.5)
            # [FIX]: Scan logs for specific Vite/NPM/esbuild failure patterns
            current_logs = "".join(stdout_lines) + "".join(stderr_lines)
            # Catching imports, esbuild errors, Babel crashes, and Vite startup failures
            error_keywords = [
                "failed to resolve import", 
                "module not found", 
                "error when starting dev server", 
                "failed to load config",
                "Γ£ÿ [error]",
                "syntax error",
                "unexpected token",
                "plugin:vite",
                "[parse_error]",
                "build failed"
            ]
            
            if any(k in current_logs.lower() for k in error_keywords):
                ui.print("[bold red]>> Build/Runtime Error detected in server logs. Triggering Healer...[/bold red]")
                process.terminate()
                return 1, "".join(stdout_lines), "".join(stderr_lines)
            
            if process.poll() is not None:
                break
                
        if process.poll() is None:
            # Still running and no import errors = Success for a server
            return 0, "".join(stdout_lines), "".join(stderr_lines)
            
    exit_code = process.wait()
    t1.join()
    t2.join()

    return exit_code, "".join(stdout_lines), "".join(stderr_lines)


def run_with_healing(
    *,
    command_args: List[str],
    cwd: str,
    model: str,
    provider: str,
    context: Optional[Dict[str, str]] = None,
    extra_context: Optional[Dict[str, str]] = None,
    repo_map: Optional[str] = None,
    max_attempts: int = 3,
) -> str:
    """
    Runs the command locally.
    On failure: calls NOVA_API /healer/run to get a patch block ([EDIT]/[SHELL]).
    Applies the patch locally via handle_ai_commands(), then retries.
    Returns "SUCCESS_SIGNAL" on success.
    Raises on final failure.
    """

    from nova_cli.local.contextifier import run_contextify

    merged_context: Dict[str, str] = {}
    if context:
        merged_context.update(context)
    if extra_context:
        merged_context.update(extra_context)
        
    try:
        from nova_cli.local.contextifier import run_contextify
        project_ctx = run_contextify(cwd, save_to_disk=False)
        merged_context.update(project_ctx)
    except Exception:
        pass

    if repo_map is None:
        try:
            repo_map = prompts.get_repo_map_cached(cwd)
        except Exception:
            repo_map = None

    api = BridgeyeAPIClient()
    last_err = ""

    # Best-effort: infer target file path
    target_abs: Optional[str] = None
    if command_args and isinstance(command_args[-1], str) and command_args[-1].lower().endswith((".py", ".js", ".mjs", ".ts", ".r", ".R")):
        t = command_args[-1]
        target_abs = t if os.path.isabs(t) else os.path.abspath(os.path.join(cwd, t))
        target_abs = target_abs.replace("\\", "/")

    # Bounded history for prompt stability
    healing_history: List[str] = []
    MAX_HISTORY_ITEMS = 6
    MAX_HISTORY_CHARS = 4000

    # Redundant scan removed. Dependency check is managed by the shell before execution.

    def _push_history(item: str) -> None:
        if not item:
            return
        healing_history.append(item[:MAX_HISTORY_CHARS])
        if len(healing_history) > MAX_HISTORY_ITEMS:
            del healing_history[:-MAX_HISTORY_ITEMS]

    def _norm_path(p: str) -> str:
        return (p or "").replace("\\", "/")

    def _basename(p: str) -> str:
        return os.path.basename(_norm_path(p))

    def _resolve_modified_paths(modified: List[str]) -> List[str]:
        """
        Convert returned modified file identifiers into real on-disk paths when possible.
        Returns absolute, normalized paths for files that exist.
        """
        out: List[str] = []
        for f in modified or []:
            if not f or f == "SYSTEM_ENVIRONMENT" or not isinstance(f, str):
                continue

            f_norm = _norm_path(f)

            candidates = []
            if os.path.isabs(f_norm):
                candidates.append(f_norm)
            else:
                candidates.append(_norm_path(os.path.abspath(os.path.join(cwd, f_norm))))

            # Also try basename in cwd if AI returned weird relative fragments
            candidates.append(_norm_path(os.path.abspath(os.path.join(cwd, os.path.basename(f_norm)))))

            found = None
            for c in candidates:
                if os.path.exists(c) and os.path.isfile(c):
                    found = c
                    break

            if found and found not in out:
                out.append(found)

        return out

    def _snapshot_files(paths: List[str]) -> Dict[str, str]:
        snap: Dict[str, str] = {}
        for p in paths:
            try:
                snap[p] = _read_text(p)
            except Exception:
                pass
        return snap

    def _revert_files(snapshot: Dict[str, str]) -> None:
        for p, content in snapshot.items():
            try:
                _write_text(p, content)
            except Exception:
                pass

    for attempt in range(1, max_attempts + 1):
        ui.print(f"[dim]>> Run attempt {attempt}/{max_attempts}: {' '.join(command_args)}[/dim]")

        exit_code, stdout, stderr = _run_once(command_args, cwd)
        full_logs = (stdout or "") + (stderr or "")
        last_err = full_logs or f"exit_code={exit_code}"

        if exit_code == 0:
            # Secondary check: if the command targets a script that was supposed to
            # produce an output file, treat "success with warning" as failure when
            # the expected output is missing or a UnicodeEncodeError was printed.
            _unicode_failed = bool(re.search(r'UnicodeEncodeError|UnicodeDecodeError', full_logs, re.IGNORECASE))
            _skipped_generation = bool(re.search(r'[Ss]kipping file generation', full_logs))
            _report_generated = bool(re.search(r'(Report generated|generated successfully|\.pdf)', full_logs, re.IGNORECASE))
            if (_unicode_failed or _skipped_generation) and not _report_generated:
                # LOCAL FIX: Don't send unicode errors to the AI healer at all.
                # The AI cannot reliably SEARCH/REPLACE unicode characters because
                # they mangle during context serialization. Fix it locally instead:
                # inject a _safe() sanitizer at the top of the file and wrap all
                # string arguments automatically via ast rewrite — or simpler: just
                # patch the output call to use latin-1 replace mode.
                if target_abs and os.path.exists(target_abs):
                    try:
                        _src = _read_text(target_abs)
                        # Only apply if not already patched
                        if "_safe_latin1" not in _src:
                            _sanitizer_inject = (
                                "\n# [NOVA AUTO-PATCH] Unicode sanitizer for latin-1 PDF encoding\n"
                                "def _safe_latin1(text):\n"
                                "    if not isinstance(text, str):\n"
                                "        text = str(text)\n"
                                "    return text.encode('latin-1', errors='replace').decode('latin-1')\n\n"
                                "import builtins as _builtins\n"
                                "_original_str = _builtins.str\n"
                            )
                            # Find the last import line and inject after it
                            import re as _re2
                            _lines = _src.splitlines(keepends=True)
                            _last_import_idx = 0
                            for _i, _line in enumerate(_lines):
                                if _re2.match(r'^(import |from )', _line.strip()):
                                    _last_import_idx = _i
                            _lines.insert(_last_import_idx + 1, _sanitizer_inject)
                            
                            # Replace pdf.output() to use latin-1 safe mode
                            _patched = "".join(_lines)
                            _patched = _re2.sub(
                                r'(pdf\.output\([^)]+\))',
                                lambda m: (
                                    "(__import__('builtins').setattr(__import__('builtins'), '_nova_pdf_out', True) or "
                                    + m.group(1).replace(
                                        "pdf.output(",
                                        "pdf.output("
                                    ) + ")"
                                ),
                                _patched
                            )
                            
                            # Simpler and more reliable: just encode all string constants
                            # by monkey-patching multi_cell and cell at runtime
                            _monkey_patch = (
                                "\n# [NOVA AUTO-PATCH] Monkey-patch fpdf methods for latin-1 safety\n"
                                "_orig_cell = ReportPDF.cell if 'ReportPDF' in dir() else None\n"
                                "def _patched_multi_cell(self, *args, **kwargs):\n"
                                "    args = tuple(_safe_latin1(a) if isinstance(a, str) else a for a in args)\n"
                                "    kwargs = {k: _safe_latin1(v) if isinstance(v, str) else v for k, v in kwargs.items()}\n"
                                "    return super(self.__class__, self).multi_cell(*args, **kwargs)\n"
                                "def _patched_cell(self, *args, **kwargs):\n"
                                "    args = tuple(_safe_latin1(a) if isinstance(a, str) else a for a in args)\n"
                                "    kwargs = {k: _safe_latin1(v) if isinstance(v, str) else v for k, v in kwargs.items()}\n"
                                "    return super(self.__class__, self).cell(*args, **kwargs)\n"
                                "ReportPDF.multi_cell = _patched_multi_cell\n"
                                "ReportPDF.cell = _patched_cell\n"
                            )
                            # Inject monkey-patch just before if __name__ == '__main__'
                            _patched_src = "".join(_lines)
                            if "if __name__" in _patched_src:
                                _patched_src = _patched_src.replace(
                                    'if __name__ == "__main__":',
                                    _monkey_patch + '\nif __name__ == "__main__":'
                                ).replace(
                                    "if __name__ == '__main__':",
                                    _monkey_patch + "\nif __name__ == '__main__':"
                                )
                            else:
                                _patched_src += _monkey_patch

                            _write_text(target_abs, _patched_src)
                            _write_text(target_abs, _patched_src)
                            ui.print("[green]>> NOVA Local Patch: Injected latin-1 unicode sanitizer into script.[/green]")
                            prompts.clear_file_tree_cache()
                            _ensure_target_file_in_context(command_args, cwd, merged_context)
                            continue  # Retry immediately with patched file
                    except Exception as _ue:
                        ui.print(f"[yellow]>> Local unicode patch failed ({_ue}), falling back to healer.[/yellow]")
            else:
                return full_logs or "SUCCESS_SIGNAL"

        err_text = (stderr or "") + "\n" + (stdout or "")

        # Local hotfix: strip UTF-8 BOM bytes that can break Python
        if (
            ("U+FEFF" in err_text or "invalid non-printable character" in err_text)
            and target_abs
            and os.path.exists(target_abs)
        ):
            if _strip_utf8_bom(target_abs):
                ui.print("[yellow]>> Detected UTF-8 BOM (U+FEFF). Removed BOM and retrying...[/yellow]")
                prompts.clear_file_tree_cache()
                _ensure_target_file_in_context(command_args, cwd, merged_context)
                continue

        # Force a fresh read from disk to avoid "search_not_found" errors
        prompts.clear_file_tree_cache()

        # REBUILD CONTEXT FROM SCRATCH - Pure state, no shell pollution
        # Re-seed from original sources so extra_context is never lost
        merged_context.clear()
        if context:
            merged_context.update(context)
        if extra_context:
            merged_context.update(extra_context)
        
        try:
            from nova_cli.local.contextifier import run_contextify
            # Generate/update project context file in the current working directory before healing
            project_ctx = run_contextify(cwd, save_to_disk=True)
            if isinstance(project_ctx, dict):
                merged_context.update(project_ctx)
            elif isinstance(project_ctx, str):
                # Inject string dumps as a virtual file so the AI can read it
                merged_context["PROJECT_CONTEXT_SUMMARY.txt"] = project_ctx
        except Exception: pass
        
        _ensure_target_file_in_context(command_args, cwd, merged_context)

        clean_stdout = (stdout or "").replace("\r\n", "\n").replace("\r", "\n")
        clean_stderr = (stderr or "").replace("\r\n", "\n").replace("\r", "\n")

        # WORKAROUND: Scrub library/site-package file references from tracebacks.
        # The backend validator parses "File <path>" lines to determine edit target.
        # If it finds a library path (e.g. fpdf.py inside site-packages) it locks
        # that as expected target and rejects any patch against the user's script.
        # We remove those lines so the validator only sees the user's file.
        import re as _re
        _lib_path_pattern = _re.compile(
            r'^\s*File ".*(?:site-packages|dist-packages|lib/python|lib\\python)[^"]*".*\n(?:.*\n)?',
            _re.MULTILINE
        )
        clean_stderr = _lib_path_pattern.sub('', clean_stderr)
        clean_stdout = _lib_path_pattern.sub('', clean_stdout)

        # Prevent massive logs from crashing the API payload limits
        MAX_LOG_CHARS = 15000
        if len(clean_stdout) > MAX_LOG_CHARS:
            clean_stdout = "\n...[STDOUT TRUNCATED DUE TO LENGTH]...\n" + clean_stdout[-MAX_LOG_CHARS:]
        if len(clean_stderr) > MAX_LOG_CHARS:
            clean_stderr = "\n...[STDERR TRUNCATED DUE TO LENGTH]...\n" + clean_stderr[-MAX_LOG_CHARS:]

        # Detect language to prevent misclassification in Healer
        lang = "Python"
        package_manager = "pip install"
        if any(arg.endswith((".r", ".R")) for arg in command_args) or command_args[0].lower() == "rscript": 
            lang = "R-Language"
            package_manager = "Rscript -e 'install.packages(...)'"
            
            # CRITICAL FIX: Scrub R noise (startup and massive rlang backtraces)
            clean_stderr = re.sub(r"Attaching package:.*?(?=\n\S|\Z)", "", clean_stderr, flags=re.DOTALL)
            clean_stderr = re.sub(r"The following objects? are masked.*?(?=\n\S|\Z)", "", clean_stderr, flags=re.DOTALL)
            # Scrub the "Backtrace:" section which confuses the AI
            clean_stderr = re.sub(r"Backtrace:.*?(\n\s*\d+\.\s+.*)+", "\n[Backtrace Truncated by NOVA]", clean_stderr, flags=re.DOTALL)
            clean_stderr = clean_stderr.strip()
            
        elif any(arg.endswith((".js", ".jsx", ".ts", ".tsx")) for arg in command_args) or command_args[0].lower() in ["npm", "npx", "node", "vite"]: 
                lang = "Node.js / Vite"
                package_manager = "npm install --force"
                
                # Identify if the missing import is a package or a local file
                if "failed to resolve import" in clean_stderr.lower() or "module not found" in clean_stderr.lower() or "is not exported" in clean_stderr.lower():
                    missing_pkg_match = re.search(r'import\s+["\']([^"\']+)["\']', clean_stderr.lower())
                    if missing_pkg_match:
                        pkg = missing_pkg_match.group(1)
                        if not pkg.startswith("."):
                            # It's an NPM package
                            base_pkg = pkg.split('/')[0] if not pkg.startswith('@') else '/'.join(pkg.split('/')[:2])
                            clean_stderr += f"\n\n[NOVA HINT: '{base_pkg}' is a missing NPM package (or missing peer dependency). Output [SHELL: npm install --force {base_pkg}]]"
                        else:
                            # It's a local file import error
                            clean_stderr += f"\n\n[NOVA HINT: '{pkg}' is a local file error. Check PROJECT_CONTEXT_SUMMARY.txt for the correct path/casing and use [EDIT] to fix the import/export.]"

                # Catch TS/JS vs TSX/JSX hallucination parse errors
                if "[parse_error]" in clean_stderr.lower() or "expected `>`" in clean_stderr.lower():
                    clean_stderr += "\n\n[NOVA HINT: This is a JSX parsing error. If the file contains React components or hooks, it MUST use the .tsx or .jsx extension. To fix this, use [DELETE: current_filename] and then use [CREATE: new_filename] with the correct extension and the exact same code, then [EDIT] the files that import it.]"

        # GAP 2 FIX: Display the actual error to the user before hiding behind the healer
        if attempt == 1 or "SYNTAX_ERROR" not in "".join(healing_history):
            # Print only the first 2000 chars to avoid terminal flood, but enough to see the stack trace
            ui.script_output(err_text[:2000], title=f"Execution Failed: {os.path.basename(target_abs) if target_abs else 'Script'}", color="red")

        with ui.create_loader(f"NOVA is analyzing {lang} logs and generating a fix..."):
            target_fname = os.path.basename(target_abs) if target_abs else "filename"
            
            # THE "FINAL" UNBREAKABLE HEALER PROTOCOL
            lang_instruction = (
                f"/// SYSTEM_STRICT_MODE ///\n"
                f"ENVIRONMENT: {lang} | RUNNING_FILE: {target_fname}\n"
                f"ONLY_VALID_EDIT_TARGET: {target_fname}\n"
                "\nRULES:\n"
                f"1. FORBIDDEN TARGETS: You may ONLY [EDIT] '{target_fname}'. "
                f"NEVER edit library files, site-packages, or any path containing 'site-packages', 'dist-packages', 'lib/python', or any installed package directory. "
                f"If the error is inside a library, fix the USAGE of that library in {target_fname} instead.\n"
                "2. NO CREATES ALLOWED: You are STRICTLY FORBIDDEN from using [CREATE] tags in this healing phase to prevent duplicate exports and file truncation. You MUST use surgical [EDIT] blocks to fix the broken lines.\n"
                "3. TSCONFIG RESOLUTION: If you detect 'tsconfig' errors, 'baseUrl' deprecation, or 'File not found' in TypeScript, you MUST rewrite tsconfig.json using: 'moduleResolution': 'bundler', 'skipLibCheck': true, and ensure 'include' contains 'src'.\n"
                "4. IMPORT ERRORS: If the error is 'ModuleNotFoundError' or 'ImportError', output EXACTLY: [SHELL: pip install <package_name>]. Do NOT try to create or edit the missing module.\n"
                "4. ANCHORS: Always include 1-2 lines of 'safe' code (like a unique comment or function header) in your SEARCH block to help the engine find the exact location.\n"
                "5. R-STATS TRANSFORMATION: In R, spaces become dots in column names. For 'NA/NaN/Inf' errors, use na.omit(). For 'cannot correct step size' or 'divergence' errors in glm(), you MUST scale() the continuous predictor variables before fitting to fix numerical instability.\n"
                "6. LOCAL IMPORT RESOLUTION: If Vite/React/TypeScript throws 'failed to resolve import' or export errors, look at the project context to find the actual path and exact casing of the file. Use an [EDIT] block to fix the import/export statement.\n"
                "7. ZERO CONVERSATION: Provide ONLY the [EDIT] or [SHELL] blocks. No explanations.\n"
                f"8. TARGET REMINDER: The file you are fixing is '{target_fname}'. Any [EDIT] block with a different filename will be REJECTED by the validator."
            )
            
            # Normalize line endings to \n to guarantee match with API validator
            # DO NOT duplicate absolute paths to prevent API Payload/Context Window overflows.
            # Preserve unicode characters exactly — do NOT encode/decode here as it mangles
            # special chars that the AI needs to match in SEARCH blocks.
            expanded_ctx = {}
            for k, v in list(merged_context.items()):
                if isinstance(v, str):
                    normalized_v = v.replace("\r\n", "\n").replace("\r", "\n")
                    expanded_ctx[k] = normalized_v
                else:
                    expanded_ctx[k] = v
            
            merged_context.clear()
            merged_context.update(expanded_ctx)
            
            try:
                merged_context["__SYSTEM_INSTRUCTIONS__"] = lang_instruction
                patch_block = api.run_with_healing(
                    command=command_args,
                    cwd=cwd,
                    model="openai/gpt-oss-120b",
                    provider="openrouter",
                    exit_code=exit_code,
                    stdout=clean_stdout,
                    stderr=clean_stderr,
                    context=merged_context,
                    repo_map=repo_map,
                    healing_history=healing_history,
                )
            except RuntimeError as _heal_err:
                _err_str = str(_heal_err).lower()
                if "search_not_found" in _err_str:
                    _push_history(
                        "SEARCH_NOT_FOUND_ERROR: Your SEARCH block did not match the file exactly. "
                        "Copy SEARCH lines CHARACTER-FOR-CHARACTER from FILE_CONTEXT — "
                        "exact whitespace, indentation, blank lines. No paraphrasing or ellipsis."
                    )
                    _ensure_target_file_in_context(command_args, cwd, merged_context)
                    continue
                if "empty_patch" in _err_str:
                    _push_history(
                        "EMPTY_PATCH_ERROR: You returned an empty response. "
                        "You MUST output a concrete [EDIT: <filename>] block with a valid SEARCH/REPLACE pair. "
                        "Read the error message and the file context carefully, "
                        "then fix the exact lines causing the failure."
                    )
                    _ensure_target_file_in_context(command_args, cwd, merged_context)
                    continue
                if "missing_edit_header" in _err_str:
                    _push_history(
                        f"MISSING_EDIT_HEADER_ERROR: Your response was rejected because it had no valid [EDIT: filename] header. "
                        f"You MUST start your response with exactly: [EDIT: {target_fname}] "
                        f"followed by SEARCH/REPLACE blocks. "
                        f"Do NOT return plain text, markdown, or explanations. "
                        f"Do NOT omit the filename in the header. "
                        f"Example format:\n"
                        f"[EDIT: {target_fname}]\n"
                        f"<<<SEARCH\n"
                        f"<exact lines from file>\n"
                        f"===\n"
                        f"<replacement lines>\n"
                        f">>>REPLACE"
                    )
                    _ensure_target_file_in_context(command_args, cwd, merged_context)
                    continue
                raise

        if not patch_block or not patch_block.strip():
            ui.display_error(
                "NOVA could not generate a valid repair patch for this error. \n\n[cyan]Please report or give feedback on support@bridgeye.com.[/cyan]",
                title="Healing Failure"
            )
            return "FAILURE_SIGNAL"

        ui.print("[cyan]>> Healer suggested patch. Applying...[/cyan]")

        # Intercept shell-only patches BEFORE applying — packages are already
        # installed by ensure_dependencies before every run attempt.
        # EXCEPTION: Allow shell-only patches on genuine import/module errors (first attempt only).
        _patch_has_code_fix = bool(re.search(r'\[(?:EDIT|CREATE):', patch_block, re.IGNORECASE))
        _patch_has_shell    = bool(re.search(r'\[SHELL:', patch_block, re.IGNORECASE))
        _is_import_error    = bool(re.search(r'(ModuleNotFoundError|ImportError|No module named)', err_text, re.IGNORECASE))
        _shell_already_tried = any("SHELL_ONLY_PATCH_BLOCKED" in h or "SHELL_ONLY_PATCH:" in h for h in healing_history)

        if not _patch_has_code_fix and _patch_has_shell and not (_is_import_error and not _shell_already_tried):
            _push_history(
                "SHELL_ONLY_PATCH_BLOCKED: Package installation was skipped — all packages "
                "were already installed by the pre-run dependency check. Packages ARE available. "
                "The failure is a CODE error. You MUST provide an [EDIT] block to fix it. "
                "Do NOT emit [SHELL: install ...] again."
            )
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # Apply patch
        modified = handle_ai_commands(patch_block, cwd=cwd)

        # Record what we tried (ONCE)
        _push_history(patch_block)

        # If nothing applied, add a corrective hint and retry
        if not modified:
            _snippet = ""
            if target_abs and os.path.exists(target_abs):
                try:
                    with open(target_abs, "r", encoding="utf-8", errors="replace") as _fh:
                        _file_lines = _fh.read().replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").splitlines(keepends=True)
                    _snippet = (
                        "\n\nVERBATIM_FILE_CONTENT (copy your SEARCH block character-for-character from here, no changes):\n"
                        + "".join(_file_lines[:50])
                    )
                except Exception:
                    pass

            _push_history(
                "EDIT_NOT_APPLIED: Your SEARCH block did NOT match the file. "
                "This is a character-level mismatch — wrong indentation, spaces, or paraphrased lines. "
                "You MUST copy SEARCH lines exactly as they appear in VERBATIM_SOURCE or VERBATIM_FILE_CONTENT. "
                "No ellipsis, no reformatting, no skipped lines inside SEARCH."
                + _snippet
            )
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # If ONLY a shell/env change ran (package install), no code was actually fixed.
        # Push a corrective hint so the AI knows to write an [EDIT] block next.
        only_env_change = all(m == "SYSTEM_ENVIRONMENT" for m in modified)
        if only_env_change:
            _push_history(
                "SHELL_ONLY_PATCH: Package installation succeeded but no [EDIT] block was applied. "
                "The required packages are now installed. "
                "NEXT: Fix the actual runtime error with an [EDIT] block. Do NOT suggest package installation again."
            )
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # Resolve real modified file paths and snapshot for possible revert
        modified_paths = _resolve_modified_paths(modified)
        snapshot = _snapshot_files(modified_paths)

        # Syntax check: if any modified .py file fails compile, revert and retry
        syntax_failed = False
        syntax_err = ""
        for p in modified_paths:
            if p.lower().endswith(".py"):
                ok, err = _syntax_check(p)
                if not ok:
                    syntax_failed = True
                    syntax_err = f"{os.path.basename(p)}: {err}"
                    break

        if syntax_failed:
            ui.print("[red]>> Edit Rejected: Resulting code has Syntax Error.[/red]")
            ui.print(f"[red]>> {syntax_err}[/red]")
            _revert_files(snapshot)
            _push_history(f"SYNTAX_ERROR_AFTER_PATCH: {syntax_err}")
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # After file ops, clear repo-map cache + refresh context for modified files
        prompts.clear_file_tree_cache()

        # REFRESH CONTEXT: Strictly re-read all modified files from disk
        for p in modified_paths:
            try:
                with open(p, "r", encoding="utf-8") as fp:
                    content = fp.read()
                
                content = content.replace("\r\n", "\n").replace("\r", "\n")
                if content.startswith("\ufeff"):
                    content = content.lstrip("\ufeff")

                abs_key = _norm_path(p)
                rel_key = None
                try:
                    rel_key = os.path.relpath(p, cwd).replace("\\", "/")
                except Exception:
                    pass

                # Remove all possible stale versions of this file from the dictionary
                # Use lower() to handle Windows case-insensitivity during refresh
                p_base = _basename(p).lower()
                keys_to_clear = [k for k in merged_context.keys() if _basename(k).lower() == p_base]
                for k in keys_to_clear:
                    del merged_context[k]

                # Re-insert fresh content
                merged_context[abs_key] = content
                if rel_key:
                    merged_context[rel_key] = content
            except Exception:
                pass

        # Force a refresh of the target file context specifically
        _ensure_target_file_in_context(command_args, cwd, merged_context)

    raise RuntimeError(f"Command failed after {max_attempts} attempts.\nLast error:\n{last_err}")



--- FILE: local/memory/__init__.py ---

from nova_cli.local.memory.index import (
    record_event,
    get_memory_context_block,
    get_latest_change,
    generate_and_store_note,
    add_manual_note,
    get_relevant_memory_context,
    search_across_projects,
    maybe_consolidate_memory,
    consolidate_now,
)

__all__ = [
    "record_event",
    "get_memory_context_block",
    "get_latest_change",
    "generate_and_store_note",
    "add_manual_note",
    "get_relevant_memory_context",
    "search_across_projects",
    "maybe_consolidate_memory",
    "consolidate_now",
]

--- FILE: local/memory/index.py ---

import time
from typing import Dict, Any, Optional

from nova_cli.local.memory.services.store import (
    get_project_entry,
    append_event,
    append_note,
    get_all_project_entries,
    mark_session_start,
    should_consolidate,
    apply_consolidation,
)
from nova_cli.local.memory.services.git_lookup import get_latest_git_commit
from nova_cli.local.memory.services.note_generator import generate_note, consolidate_notes
from nova_cli.local.memory.services.semantic_search import semantic_rank
from nova_cli.local.memory.services.manual_notes import load_manual_notes, ensure_manual_notes_files


def record_event(
    cwd: str,
    intent: str,
    request_summary: str,
    outcome: str,
    files_changed: Optional[list] = None,
) -> None:
    """Records a completed task into the global memory log for this project."""
    event = {
        "timestamp": time.time(),
        "intent": intent,
        "request_summary": (request_summary or "")[:300],
        "outcome": (outcome or "")[:300],
        "files_changed": files_changed or [],
    }
    append_event(cwd, event)


def generate_and_store_note(
    cwd: str,
    intent: str,
    request_summary: str,
    outcome: str,
    model: str = "openai/gpt-oss-120b",
    provider: str = "openrouter",
) -> None:
    """Best-effort: asks the AI to judge if this turn produced something worth remembering, and stores it if so."""
    note_text = generate_note(intent, request_summary, outcome, model=model, provider=provider)
    if not note_text:
        return
    note = {
        "timestamp": time.time(),
        "text": note_text,
        "source": "auto",
    }
    append_note(cwd, note)


def add_manual_note(cwd: str, text: str) -> None:
    """Stores a user-forced knowledge note without any AI judgment call."""
    note = {
        "timestamp": time.time(),
        "text": (text or "").strip()[:300],
        "source": "manual",
    }
    append_note(cwd, note)


def get_memory_context_block(cwd: str, max_recent: int = 10) -> str:
    """Returns a formatted block for prompt injection describing prior work on this project."""
    ensure_manual_notes_files(cwd)
    manual_block = load_manual_notes(cwd)

    entry = get_project_entry(cwd)
    if not entry:
        if manual_block:
            return manual_block + "\n\n[NO PRIOR AUTOMATIC MEMORY FOR THIS PROJECT]"
        return "[NO PRIOR MEMORY FOR THIS PROJECT]"

    lines = []
    if manual_block:
        lines.append(manual_block)
    lines.append(f"Project: {entry.get('name')}")
    if entry.get("summary"):
        lines.append("Earlier history summary:")
        lines.append(entry["summary"])

    events = entry.get("events", [])[-max_recent:]
    if events:
        lines.append("Recent activity:")
        for ev in events:
            ts = ev.get("timestamp")
            date_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "unknown"
            lines.append(f"- [{date_str}] {ev.get('intent')}: {ev.get('request_summary')} -> {ev.get('outcome')}")

    if entry.get("notes_summary"):
        lines.append("Earlier knowledge summary:")
        lines.append(entry["notes_summary"])

    notes = entry.get("notes", [])[-max_recent:]
    if notes:
        lines.append("Known facts / conventions / decisions for this project:")
        for note in notes:
            ts = note.get("timestamp")
            date_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "unknown"
            lines.append(f"- [{date_str}] ({note.get('source')}) {note.get('text')}")

    return "\n".join(lines)


def get_relevant_memory_context(cwd: str, query: str, top_k: int = 5) -> str:
    """
    Returns a context block containing the most semantically relevant notes/events
    for the given query, using embedding-based ranking with a local fuzzy fallback.
    """
    manual_block = load_manual_notes(cwd)

    entry = get_project_entry(cwd)
    if not entry:
        if manual_block:
            return manual_block + "\n\n[NO PRIOR AUTOMATIC MEMORY FOR THIS PROJECT]"
        return "[NO PRIOR MEMORY FOR THIS PROJECT]"

    candidates = []
    for ev in entry.get("events", []):
        candidates.append({
            "type": "event",
            "text": f"{ev.get('intent')}: {ev.get('request_summary')} -> {ev.get('outcome')}",
            "timestamp": ev.get("timestamp"),
        })
    for note in entry.get("notes", []):
        candidates.append({
            "type": "note",
            "text": note.get("text", ""),
            "timestamp": note.get("timestamp"),
        })

    if not candidates:
        return "[NO PRIOR MEMORY FOR THIS PROJECT]"

    ranked = semantic_rank(query, candidates, top_k=top_k)

    lines = []
    if manual_block:
        lines.append(manual_block)
    lines.append(f"Project: {entry.get('name')}")
    if entry.get("summary"):
        lines.append("Earlier history summary:")
        lines.append(entry["summary"])
    if entry.get("notes_summary"):
        lines.append("Earlier knowledge summary:")
        lines.append(entry["notes_summary"])

    if ranked:
        lines.append("Most relevant prior context for this question:")
        for cand, score in ranked:
            ts = cand.get("timestamp")
            date_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "unknown"
            lines.append(f"- [{date_str}] ({cand.get('type')}) {cand.get('text')}")

    return "\n".join(lines)


def search_across_projects(query: str, top_k: int = 5) -> str:
    """
    Semantically searches notes/events across ALL known projects for the given query.
    Returns a formatted context block naming which project each match came from.
    """
    all_projects = get_all_project_entries()
    if not all_projects:
        return "[NO MEMORY RECORDED FOR ANY PROJECT YET]"

    candidates = []
    for path, entry in all_projects.items():
        project_name = entry.get("name", path)
        for ev in entry.get("events", []):
            candidates.append({
                "type": "event",
                "project": project_name,
                "path": path,
                "text": f"{ev.get('intent')}: {ev.get('request_summary')} -> {ev.get('outcome')}",
                "timestamp": ev.get("timestamp"),
            })
        for note in entry.get("notes", []):
            candidates.append({
                "type": "note",
                "project": project_name,
                "path": path,
                "text": note.get("text", ""),
                "timestamp": note.get("timestamp"),
            })

    if not candidates:
        return "[NO MEMORY RECORDED FOR ANY PROJECT YET]"

    ranked = semantic_rank(query, candidates, top_k=top_k)

    if not ranked:
        return "[NO RELEVANT MATCHES FOUND ACROSS PROJECTS]"

    lines = ["Cross-project search results:"]
    for cand, score in ranked:
        ts = cand.get("timestamp")
        date_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "unknown"
        lines.append(f"- [{cand.get('project')}] [{date_str}] ({cand.get('type')}) {cand.get('text')}")

    return "\n".join(lines)


def maybe_consolidate_memory(cwd: str) -> None:
    """
    Best-effort consolidation pass, mirroring Claude Code's Auto Dream.
    Triggered automatically once enough sessions and idle time have passed since the last consolidation.
    """
    try:
        entry = mark_session_start(cwd)
        if not should_consolidate(entry):
            return

        notes_texts = [n.get("text", "") for n in entry.get("notes", [])]
        new_summary = consolidate_notes(entry.get("notes_summary", ""), notes_texts)
        if new_summary:
            apply_consolidation(cwd, new_summary)
    except Exception:
        pass


def consolidate_now(cwd: str) -> bool:
    """Manually forces a consolidation pass regardless of automatic thresholds. Returns True if it ran."""
    try:
        entry = get_project_entry(cwd)
        if not entry or not entry.get("notes"):
            return False

        notes_texts = [n.get("text", "") for n in entry.get("notes", [])]
        new_summary = consolidate_notes(entry.get("notes_summary", ""), notes_texts)
        if new_summary:
            apply_consolidation(cwd, new_summary)
            return True
        return False
    except Exception:
        return False


def get_latest_change(cwd: str) -> Dict[str, Any]:
    """
    Resolves 'what was the latest change' for a project.
    Priority: memory log events -> git log fallback.
    """
    entry = get_project_entry(cwd)
    if entry and entry.get("events"):
        last_event = entry["events"][-1]
        ts = last_event.get("timestamp")
        date_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else "unknown"
        return {
            "source": "memory",
            "timestamp": date_str,
            "intent": last_event.get("intent"),
            "request_summary": last_event.get("request_summary"),
            "outcome": last_event.get("outcome"),
            "files_changed": last_event.get("files_changed", []),
        }

    commit = get_latest_git_commit(cwd)
    if commit:
        return {
            "source": "git",
            "timestamp": commit["committed_at"],
            "message": commit["message"],
            "hash": commit["hash"],
            "author": commit["author"],
        }

    return {"source": "none"}

--- FILE: local/memory/services/__init__.py ---



--- FILE: local/memory/services/embedding_cache.py ---

import os
import json
import hashlib
from typing import Dict, List, Optional, Callable

CACHE_DIR = os.path.expanduser("~/.nova")
CACHE_FILE = os.path.join(CACHE_DIR, "embeddings_cache.json")


def _hash_text(text: str) -> str:
    return hashlib.sha256((text or "").encode("utf-8")).hexdigest()


def load_cache() -> Dict[str, List[float]]:
    if not os.path.exists(CACHE_FILE):
        return {}
    try:
        with open(CACHE_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
            if isinstance(data, dict):
                return data
            return {}
    except Exception:
        return {}


def save_cache(cache: Dict[str, List[float]]) -> None:
    os.makedirs(CACHE_DIR, exist_ok=True)
    tmp_path = CACHE_FILE + ".tmp"
    with open(tmp_path, "w", encoding="utf-8") as f:
        json.dump(cache, f)
    os.replace(tmp_path, CACHE_FILE)


def get_embeddings_with_cache(
    texts: List[str],
    embed_fn: Callable[[List[str]], List[List[float]]],
) -> Dict[str, List[float]]:
    """
    Returns a mapping of text -> embedding, using the local cache where possible.
    embed_fn is called only for texts missing from cache, minimizing API calls.
    """
    cache = load_cache()
    result: Dict[str, List[float]] = {}
    missing_texts: List[str] = []
    missing_hashes: List[str] = []

    for text in texts:
        h = _hash_text(text)
        if h in cache:
            result[text] = cache[h]
        else:
            missing_texts.append(text)
            missing_hashes.append(h)

    if missing_texts:
        new_embeddings = embed_fn(missing_texts)
        if new_embeddings and len(new_embeddings) == len(missing_texts):
            for h, text, emb in zip(missing_hashes, missing_texts, new_embeddings):
                cache[h] = emb
                result[text] = emb
            save_cache(cache)

    return result

--- FILE: local/memory/services/git_lookup.py ---

from typing import Optional, Dict, Any


def get_latest_git_commit(cwd: str) -> Optional[Dict[str, Any]]:
    """Falls back to git log to find the most recent commit for a project Nova has no memory of."""
    try:
        import git
        repo = git.Repo(cwd, search_parent_directories=True)
        if not repo.head.is_valid():
            return None
        commit = repo.head.commit
        return {
            "hash": commit.hexsha[:8],
            "message": commit.message.strip(),
            "author": str(commit.author),
            "committed_at": commit.committed_datetime.strftime("%Y-%m-%d %H:%M:%S"),
        }
    except Exception:
        return None

--- FILE: local/memory/services/manual_notes.py ---

import os

GLOBAL_NOTES_DIR = os.path.expanduser("~/.nova")
GLOBAL_NOTES_FILE = os.path.join(GLOBAL_NOTES_DIR, "NOVA.md")

PROJECT_NOTES_TEMPLATE = (
    "<!-- Nova reads this file at the start of every session in this project. -->\n"
    "<!-- Add anything you want Nova to always remember here, in your own words. -->\n"
)

GLOBAL_NOTES_TEMPLATE = (
    "<!-- Nova reads this file at the start of every session, across all projects. -->\n"
    "<!-- Add anything you want Nova to always remember globally here. -->\n"
)


def get_project_notes_path(cwd: str) -> str:
    return os.path.join(os.path.abspath(cwd), ".nova", "NOVA.md")


def ensure_manual_notes_files(cwd: str) -> None:
    """Creates empty template manual-notes files if they don't exist yet, for discoverability."""
    try:
        os.makedirs(GLOBAL_NOTES_DIR, exist_ok=True)
        if not os.path.exists(GLOBAL_NOTES_FILE):
            with open(GLOBAL_NOTES_FILE, "w", encoding="utf-8") as f:
                f.write(GLOBAL_NOTES_TEMPLATE)
    except Exception:
        pass

    try:
        project_path = get_project_notes_path(cwd)
        os.makedirs(os.path.dirname(project_path), exist_ok=True)
        if not os.path.exists(project_path):
            with open(project_path, "w", encoding="utf-8") as f:
                f.write(PROJECT_NOTES_TEMPLATE)
    except Exception:
        pass


def load_manual_notes(cwd: str) -> str:
    """
    Loads user hand-written manual notes: global file first, then project-specific file.
    Both are always injected regardless of query, mirroring CLAUDE.md's static injection.
    """
    blocks = []

    try:
        if os.path.exists(GLOBAL_NOTES_FILE):
            with open(GLOBAL_NOTES_FILE, "r", encoding="utf-8") as f:
                content = f.read().strip()
                if content and content != GLOBAL_NOTES_TEMPLATE.strip():
                    blocks.append(f"GLOBAL MANUAL NOTES:\n{content}")
    except Exception:
        pass

    try:
        project_path = get_project_notes_path(cwd)
        if os.path.exists(project_path):
            with open(project_path, "r", encoding="utf-8") as f:
                content = f.read().strip()
                if content and content != PROJECT_NOTES_TEMPLATE.strip():
                    blocks.append(f"PROJECT MANUAL NOTES:\n{content}")
    except Exception:
        pass

    return "\n\n".join(blocks)

--- FILE: local/memory/services/note_generator.py ---

from typing import Optional, List

from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient

NOTE_SYSTEM_PROMPT = (
    "SYSTEM_OVERRIDE: You are NOVA's memory curator.\n"
    "You will be shown a summary of a single completed task on a software project.\n"
    "Decide if this task produced information worth remembering for FUTURE sessions on this SAME project "
    "(e.g. an architecture decision, a discovered convention, a gotcha, a user preference, a workaround).\n"
    "Do NOT record routine or trivial actions (e.g. minor text tweaks, simple greetings, one-off questions with no lasting relevance).\n"
    "RULES:\n"
    "1. If nothing is worth remembering, respond with exactly: NONE\n"
    "2. If something is worth remembering, respond with ONE concise sentence (max 200 characters), no preamble, no markdown, no quotes.\n"
    "3. Never repeat the raw request or outcome verbatim; extract the underlying insight or fact."
)


def generate_note(
    intent: str,
    request_summary: str,
    outcome: str,
    model: str = "openai/gpt-oss-120b",
    provider: str = "openrouter",
) -> Optional[str]:
    """
    Calls the AI to judge whether a completed task is worth remembering.
    Returns the curated note text, or None if nothing is worth keeping or the call fails.
    """
    try:
        api = BridgeyeAPIClient()
        prompt = (
            f"{NOTE_SYSTEM_PROMPT}\n\n"
            f"INTENT: {intent}\n"
            f"REQUEST: {request_summary}\n"
            f"OUTCOME: {outcome}\n"
        )
        result = api.chat(prompt=prompt, context={}, model=model, provider=provider)
        result = (result or "").strip()
        if not result or result.upper() == "NONE":
            return None
        return result[:200]
    except Exception:
        return None


CONSOLIDATION_SYSTEM_PROMPT = (
    "SYSTEM_OVERRIDE: You are NOVA's memory curator performing a consolidation pass.\n"
    "You will be shown accumulated knowledge notes about a software project, including an existing summary "
    "and a list of newer individual notes.\n"
    "TASK: Merge all of this into ONE compact, de-duplicated summary that preserves every distinct fact, "
    "decision, convention, or preference. Remove redundancy and resolve contradictions by keeping the "
    "most recent statement when two notes conflict.\n"
    "RULES:\n"
    "1. Output ONLY the merged summary as plain text bullet points (one fact per line, prefixed with '- ').\n"
    "2. Do not add commentary, headers, or explanations.\n"
    "3. Do not invent information not present in the input.\n"
    "4. Keep it as concise as possible while preserving all distinct facts."
)


def consolidate_notes(
    existing_summary: str,
    notes_texts: List[str],
    model: str = "openai/gpt-oss-120b",
    provider: str = "openrouter",
) -> Optional[str]:
    """
    Calls the AI to merge an existing notes summary with newer individual notes into one compact summary.
    Returns the consolidated summary text, or None if the call fails.
    """
    try:
        api = BridgeyeAPIClient()
        notes_block = "\n".join(f"- {t}" for t in notes_texts if t)
        prompt = (
            f"{CONSOLIDATION_SYSTEM_PROMPT}\n\n"
            f"EXISTING_SUMMARY:\n{existing_summary or '[NONE]'}\n\n"
            f"NEWER_NOTES:\n{notes_block or '[NONE]'}\n"
        )
        result = api.chat(prompt=prompt, context={}, model=model, provider=provider)
        result = (result or "").strip()
        if not result:
            return None
        return result
    except Exception:
        return None

--- FILE: local/memory/services/semantic_search.py ---

import math
import difflib
from typing import List, Dict, Any, Tuple

from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli.local.memory.services.embedding_cache import get_embeddings_with_cache


def _cosine_similarity(a: List[float], b: List[float]) -> float:
    if not a or not b or len(a) != len(b):
        return 0.0
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(y * y for y in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


def _fallback_fuzzy_rank(
    query: str,
    candidates: List[Dict[str, Any]],
    top_k: int,
) -> List[Tuple[Dict[str, Any], float]]:
    """Local, dependency-free fallback ranking used when the embedding API is unreachable."""
    scored = []
    for cand in candidates:
        text = cand.get("text", "")
        score = difflib.SequenceMatcher(None, query.lower(), text.lower()).ratio()
        scored.append((cand, score))
    scored.sort(key=lambda x: x[1], reverse=True)
    return scored[:top_k]


def semantic_rank(
    query: str,
    candidates: List[Dict[str, Any]],
    top_k: int = 5,
) -> List[Tuple[Dict[str, Any], float]]:
    """
    Ranks candidate memory items (notes/events) by semantic similarity to the query.
    Each candidate must contain a "text" key.
    Falls back to local fuzzy matching if the embedding API call fails.
    """
    if not candidates:
        return []

    texts = [c.get("text", "") for c in candidates]

    try:
        api = BridgeyeAPIClient()

        def embed_fn(missing_texts: List[str]) -> List[List[float]]:
            return api.embed_texts(missing_texts)

        embeddings_map = get_embeddings_with_cache(texts, embed_fn)
        query_embedding_map = get_embeddings_with_cache([query], embed_fn)
        query_vec = query_embedding_map.get(query)

        if not query_vec or not embeddings_map:
            raise RuntimeError("Embedding data unavailable")

        scored = []
        for cand, text in zip(candidates, texts):
            vec = embeddings_map.get(text)
            score = _cosine_similarity(query_vec, vec) if vec else 0.0
            scored.append((cand, score))

        scored.sort(key=lambda x: x[1], reverse=True)
        return scored[:top_k]

    except Exception:
        return _fallback_fuzzy_rank(query, candidates, top_k)

--- FILE: local/memory/services/store.py ---

import os
import json
import time
from typing import Dict, Any, Optional

from nova_cli.local.memory.utils.summarizer import summarize_events, summarize_notes

GLOBAL_MEMORY_DIR = os.path.expanduser("~/.nova")
GLOBAL_MEMORY_FILE = os.path.join(GLOBAL_MEMORY_DIR, "global_memory.json")

MAX_EVENTS_PER_PROJECT = 50
MAX_NOTES_PER_PROJECT = 50


def get_project_key(cwd: str) -> str:
    """Returns a normalized absolute path used as the unique project identifier."""
    return os.path.abspath(cwd).replace("\\", "/")


def _ensure_memory_dir() -> None:
    os.makedirs(GLOBAL_MEMORY_DIR, exist_ok=True)


def load_global_memory() -> Dict[str, Any]:
    """Loads the global memory file, creating an empty structure if missing or corrupt."""
    _ensure_memory_dir()
    if not os.path.exists(GLOBAL_MEMORY_FILE):
        return {"projects": {}}
    try:
        with open(GLOBAL_MEMORY_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
            if not isinstance(data, dict) or "projects" not in data:
                return {"projects": {}}
            return data
    except Exception:
        return {"projects": {}}


def save_global_memory(data: Dict[str, Any]) -> None:
    _ensure_memory_dir()
    tmp_path = GLOBAL_MEMORY_FILE + ".tmp"
    with open(tmp_path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    os.replace(tmp_path, GLOBAL_MEMORY_FILE)


def get_project_entry(cwd: str) -> Optional[Dict[str, Any]]:
    data = load_global_memory()
    key = get_project_key(cwd)
    return data.get("projects", {}).get(key)


def get_all_project_entries() -> Dict[str, Dict[str, Any]]:
    """Returns every project's memory entry, keyed by project path."""
    data = load_global_memory()
    return data.get("projects", {})


def append_event(cwd: str, event: Dict[str, Any]) -> None:
    """Appends a new event to the project's event log, enforcing the retention cap."""
    data = load_global_memory()
    key = get_project_key(cwd)
    projects = data.setdefault("projects", {})

    entry = projects.get(key)
    if not entry:
        entry = {
            "path": key,
            "name": os.path.basename(key.rstrip("/")) or key,
            "created_at": time.time(),
            "last_updated": time.time(),
            "events": [],
            "summary": "",
            "notes": [],
            "notes_summary": "",
        }
    entry.setdefault("notes", [])
    entry.setdefault("notes_summary", "")

    entry["events"].append(event)
    entry["last_updated"] = time.time()

    if len(entry["events"]) > MAX_EVENTS_PER_PROJECT:
        overflow_count = len(entry["events"]) - MAX_EVENTS_PER_PROJECT
        old_events = entry["events"][:overflow_count]
        entry["events"] = entry["events"][overflow_count:]
        rolled_up = summarize_events(old_events)
        entry["summary"] = (entry.get("summary", "") + "\n" + rolled_up).strip()

    projects[key] = entry
    save_global_memory(data)


def append_note(cwd: str, note: Dict[str, Any]) -> None:
    """Appends a curated knowledge note to the project's notes log, enforcing the retention cap."""
    data = load_global_memory()
    key = get_project_key(cwd)
    projects = data.setdefault("projects", {})

    entry = projects.get(key)
    if not entry:
        entry = {
            "path": key,
            "name": os.path.basename(key.rstrip("/")) or key,
            "created_at": time.time(),
            "last_updated": time.time(),
            "events": [],
            "summary": "",
            "notes": [],
            "notes_summary": "",
        }
    entry.setdefault("notes", [])
    entry.setdefault("notes_summary", "")

    entry["notes"].append(note)
    entry["last_updated"] = time.time()

    if len(entry["notes"]) > MAX_NOTES_PER_PROJECT:
        overflow_count = len(entry["notes"]) - MAX_NOTES_PER_PROJECT
        old_notes = entry["notes"][:overflow_count]
        entry["notes"] = entry["notes"][overflow_count:]
        rolled_up = summarize_notes(old_notes)
        entry["notes_summary"] = (entry.get("notes_summary", "") + "\n" + rolled_up).strip()

    projects[key] = entry
    save_global_memory(data)


CONSOLIDATION_SESSION_THRESHOLD = 5
CONSOLIDATION_IDLE_HOURS_THRESHOLD = 24


def mark_session_start(cwd: str) -> Dict[str, Any]:
    """
    Called once per new Nova session for this project.
    Increments the session counter used to decide when consolidation should run.
    Returns the (possibly newly created) project entry.
    """
    data = load_global_memory()
    key = get_project_key(cwd)
    projects = data.setdefault("projects", {})

    entry = projects.get(key)
    if not entry:
        entry = {
            "path": key,
            "name": os.path.basename(key.rstrip("/")) or key,
            "created_at": time.time(),
            "last_updated": time.time(),
            "events": [],
            "summary": "",
            "notes": [],
            "notes_summary": "",
            "sessions_since_consolidation": 0,
            "last_consolidated_at": time.time(),
        }

    entry.setdefault("notes", [])
    entry.setdefault("notes_summary", "")
    entry.setdefault("sessions_since_consolidation", 0)
    entry.setdefault("last_consolidated_at", entry.get("created_at", time.time()))

    entry["sessions_since_consolidation"] += 1

    projects[key] = entry
    save_global_memory(data)
    return entry


def should_consolidate(entry: Dict[str, Any]) -> bool:
    """Mirrors Claude Code's Auto Dream trigger: enough sessions AND enough idle time since last consolidation."""
    if not entry or not entry.get("notes"):
        return False

    sessions_since = entry.get("sessions_since_consolidation", 0)
    last_consolidated_at = entry.get("last_consolidated_at", entry.get("created_at", time.time()))
    hours_since = (time.time() - last_consolidated_at) / 3600.0

    return sessions_since >= CONSOLIDATION_SESSION_THRESHOLD and hours_since >= CONSOLIDATION_IDLE_HOURS_THRESHOLD


def apply_consolidation(cwd: str, new_summary: str, keep_recent: int = 3) -> None:
    """Replaces the notes log with a compressed summary, keeping only the most recent notes intact."""
    data = load_global_memory()
    key = get_project_key(cwd)
    projects = data.setdefault("projects", {})

    entry = projects.get(key)
    if not entry:
        return

    recent_notes = entry.get("notes", [])[-keep_recent:] if keep_recent > 0 else []
    entry["notes_summary"] = new_summary
    entry["notes"] = recent_notes
    entry["sessions_since_consolidation"] = 0
    entry["last_consolidated_at"] = time.time()
    entry["last_updated"] = time.time()

    projects[key] = entry
    save_global_memory(data)

--- FILE: local/memory/utils/__init__.py ---



--- FILE: local/memory/utils/summarizer.py ---

import time
from typing import List, Dict, Any


def summarize_events(events: List[Dict[str, Any]]) -> str:
    """Deterministically rolls up older events into a compact summary line per entry."""
    lines = []
    for ev in events:
        ts = ev.get("timestamp")
        date_str = time.strftime("%Y-%m-%d", time.localtime(ts)) if ts else "unknown date"
        intent = ev.get("intent", "UNKNOWN")
        request_summary = ev.get("request_summary", "")
        outcome = ev.get("outcome", "")
        lines.append(f"- [{date_str}] {intent}: {request_summary} -> {outcome}")
    return "\n".join(lines)


def summarize_notes(notes: List[Dict[str, Any]]) -> str:
    """Deterministically rolls up older curated notes into a compact summary line per entry."""
    lines = []
    for note in notes:
        ts = note.get("timestamp")
        date_str = time.strftime("%Y-%m-%d", time.localtime(ts)) if ts else "unknown date"
        text = note.get("text", "")
        source = note.get("source", "auto")
        lines.append(f"- [{date_str}] ({source}) {text}")
    return "\n".join(lines)

--- FILE: nova_core/__init__.py ---



--- FILE: nova_core/ai/__init__.py ---



--- FILE: nova_core/ai/api_client.py ---

# NOVA-CLI\nova_cli\nova_core\ai\api_client.py

import os
import requests
from typing import List, Optional, Dict, Any, Iterator
import json

from nova_cli.nova_core.auth.storage import (
    get_access_token,
    get_refresh_token,
    update_tokens,
)
from nova_cli.nova_core.auth.client import NovaAuthClient


class BridgeyeAPIClient:
    """
    Thin HTTP client used by Nova CLI.
    This client contains NO AI logic.
    """

    def __init__(self, base_url: Optional[str] = None, timeout: int = 1200):
        env_url = os.getenv("NOVA_API_BASE_URL")
        self.base_url = (base_url or env_url or "https://api.nova.bridgeye.com").rstrip("/")
        self.timeout = timeout
        self._last_refresh_error: Optional[Dict[str, Any]] = None

        self.user_agent = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")
        # Set NOVA_DEBUG_AUTH=1 if you want verbose auth errors locally
        self.debug_auth = os.getenv("NOVA_DEBUG_AUTH", "").strip() in ("1", "true", "TRUE", "yes", "YES")

    def _headers(self) -> Dict[str, str]:
        headers: Dict[str, str] = {"User-Agent": self.user_agent}
        tok = get_access_token()
        if tok:
            headers["Authorization"] = f"Bearer {tok}"
        return headers

    def _debug(self, msg: str) -> None:
        if not self.debug_auth:
            return
        try:
            print(msg)
        except Exception:
            pass

    def _try_refresh(self) -> bool:
        rt = get_refresh_token()
        if not rt:
            self._last_refresh_error = {"kind": "no_refresh_token_on_disk"}
            return False

        auth = NovaAuthClient()
        resp = auth.refresh_access(rt)

        if not isinstance(resp, dict):
            self._last_refresh_error = {"kind": "refresh_bad_response_type", "type": str(type(resp))}
            self._debug(f"[auth] refresh failed: {self._last_refresh_error}")
            return False

        if resp.get("error"):
            self._last_refresh_error = {
                "kind": resp.get("error"),
                "status": resp.get("status"),
                "body": resp.get("body"),
            }
            self._debug(f"[auth] refresh failed: {self._last_refresh_error}")
            return False

        new_access = resp.get("access_token")
        new_refresh = resp.get("refresh_token")
        expires_in = resp.get("expires_in")
        issued_at = resp.get("issued_at")

        if not new_access or not new_refresh or not expires_in:
            self._last_refresh_error = {
                "kind": "refresh_missing_fields",
                "has_access": bool(new_access),
                "has_refresh": bool(new_refresh),
                "expires_in": expires_in,
            }
            self._debug(f"[auth] refresh failed: {self._last_refresh_error} resp={resp}")
            return False

        update_tokens(
            access_token=new_access,
            refresh_token=new_refresh,
            expires_in=int(expires_in),
            issued_at=float(issued_at) if issued_at else None,
        )

        self._last_refresh_error = None
        return True

    def _require_auth(self) -> str:
        tok = get_access_token()
        if tok:
            return tok

        if self._try_refresh():
            tok = get_access_token()
            if tok:
                return tok

        # Prettified Auth Error (Consistent with _parse_json)
        raise RuntimeError("Your session has expired or is invalid. Please run [bold]nova login[/bold] to continue.")

    def _post_with_auth_retry(self, path: str, payload: Dict[str, Any]) -> requests.Response:
        self._require_auth()
        url = f"{self.base_url}{path}"

        try:
            resp = requests.post(
                url,
                json=payload,
                headers=self._headers(),
                timeout=self.timeout,
            )
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError("Please check your internet connection. NOVA is unable to reach the server.")
        except requests.RequestException:
            raise RuntimeError("Network instability detected. Please check your internet and try again.")

        if resp.status_code != 401:
            return resp

        if self._try_refresh():
            try:
                resp = requests.post(
                    url,
                    json=payload,
                    headers=self._headers(),
                    timeout=self.timeout,
                )
            except requests.RequestException as e:
                raise RuntimeError(f"API request failed after refresh: {e}")

        return resp

    def _get_with_auth_retry(self, path: str) -> requests.Response:
        self._require_auth()
        url = f"{self.base_url}{path}"

        try:
            resp = requests.get(
                url,
                headers=self._headers(),
                timeout=self.timeout,
            )
        except requests.RequestException as e:
            raise RuntimeError(f"API request failed: {e}")

        if resp.status_code != 401:
            return resp

        if self._try_refresh():
            try:
                resp = requests.get(
                    url,
                    headers=self._headers(),
                    timeout=self.timeout,
                )
            except requests.RequestException as e:
                raise RuntimeError(f"API request failed after refresh: {e}")

        return resp

    def _parse_json(self, resp: requests.Response) -> Dict[str, Any]:
        if resp.status_code == 402:
            raise RuntimeError("Usage limit reached. Please check your account credits or upgrade your plan by visiting [bold cyan][link=https://nova.bridgeye.com/plans]https://nova.bridgeye.com/plans[/link][/bold cyan]")
        
        if resp.status_code == 401:
            raise RuntimeError("Your session has expired. Please run [bold]login[/bold] to continue.")

        if resp.status_code in (413, 429):
            raise RuntimeError("The selected model is currently at capacity. Please try again or switch models using [bold cyan]:model[/bold cyan].")

        if resp.status_code in (500, 502, 503, 504):
            raise RuntimeError("Connection error. The server is unreachable or timed out. Please try again in a few seconds.")

        try:
            data = resp.json()
            if isinstance(data, dict) and not data.get("success", True):
                err_raw = data.get("error", "")
                err_msg = err_raw.lower()
                if any(k in err_msg for k in ["rate_limit", "tpm", "tokens", "capacity"]):
                    raise RuntimeError("Model capacity reached. Please switch models using [bold cyan]:model[/bold cyan].")
                if "402" in err_msg or "insufficient credits" in err_msg:
                    raise RuntimeError("We are currently experiencing temporary connectivity issues with our AI providers. Our team has been notified. Please try again shortly.")
                # Expose the exact API detail seamlessly so it's debuggable in the terminal
                raise RuntimeError(f"An internal process error occurred. Please try again. (Detail: {err_raw})")
            return data
        except (ValueError, KeyError):
            raise RuntimeError("Invalid response from server. Please check your network and try again.")

    def health(self) -> bool:
        """
        Simple reachability check for NOVA_API.
        Assumes NOVA_API exposes GET /health -> 200 OK.
        """
        url = f"{self.base_url}/health"
        try:
            r = requests.get(url, timeout=10, headers={"User-Agent": self.user_agent})
            return r.status_code == 200
        except Exception:
            return False

    def chat(self, prompt: str, context: Optional[Dict[str, str]], model: str, provider: str, repo_map: Optional[str] = None, active_file: Optional[str] = None) -> str:
        """
        Calls NOVA_API chat endpoint with optional repository map.
        """
        payload = {
            "prompt": prompt,
            "context": context or {},
            "model": model,
            "provider": provider,
            "repo_map": repo_map,
            "active_file": active_file
        }

        tried: List[str] = []
        last_error: Optional[str] = None

        for path in ("/chat", "/chat/run"):
            tried.append(path)
            resp = self._post_with_auth_retry(path, payload)

            if resp.status_code == 401:
                raise RuntimeError("Unauthorized. Run: login")

            if resp.status_code == 404:
                last_error = f"404 on {path}"
                continue

            data = self._parse_json(resp)

            if not data.get("success"):
                raise RuntimeError(data.get("error") or "Chat failed")

            return data.get("output", "") or ""

        raise RuntimeError(
            f"Chat endpoint not found. Tried: {', '.join(tried)}. Last error: {last_error or 'unknown'}"
        )

    def enhance_prompt(
        self,
        user_prompt: str,
        model: str,
        provider: str,
        current_enhanced_prompt: Optional[str] = None,
        edit_request: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Calls NOVA_API prompt enhancement endpoint.

        Supports:
        - initial prompt enhancement
        - revision enhancement with edit instructions
        """

        payload = {
            "user_prompt": user_prompt,
            "model": model,
            "provider": provider,
            "current_enhanced_prompt": current_enhanced_prompt,
            "edit_request": edit_request,
        }

        resp = self._post_with_auth_retry("/prompt/enhance", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Prompt enhancement failed")

        return data
    
    def validate_plan(
        self,
        plan_content: str,
        model: str,
        provider: str,
        is_web_build: bool = False,
        registry_list: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Calls NOVA_API plan validation endpoint.

        Args:
            is_web_build: When True, the server applies website-specific
                completeness criteria (Design Aesthetic, Animation Profile,
                section/image density mandates) instead of the generic
                Objective/Architecture/File Structure/Steps check.

        Returns:
            {
                "success": bool,
                "is_valid": bool,
                "improved_plan": Optional[str],
                "error": Optional[str]
            }
        """
        payload = {
            "plan_content": plan_content,
            "model": model,
            "provider": provider,
            "is_web_build": is_web_build,
            "registry_list": registry_list,
        }

        resp = self._post_with_auth_retry("/plan/validate", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Plan validation failed")

        return data

    def classify_intent(self, prompt: str) -> Dict[str, Any]:
        """
        Calls NOVA_API intent classification endpoint to determine the user's intent 
        using the backend Semantic ML classifier.
        """
        payload = {"prompt": prompt}

        resp = self._post_with_auth_retry("/intent/classify", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Intent classification failed")

        return data
    
    def sync_map(self, repo_map: str) -> bool:
        """Transmits the Project Blueprint to NOVA API."""
        resp = self._post_with_auth_retry("/repo/sync-map", {"repo_map": repo_map})
        return resp.status_code == 200

    def generate_commit_message(
        self,
        diff_text: str,
        model: str,
        provider: str,
    ) -> str:
        """
        Calls NOVA_API commit message generation endpoint.

        Returns:
            str -> generated commit message
        """
        payload = {
            "diff_text": diff_text,
            "model": model,
            "provider": provider,
        }

        resp = self._post_with_auth_retry("/commit/message", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Commit message generation failed")

        return data.get("commit_message", "") or ""


    def refactor(self, filename: str, content: str, model: str, provider: str, repo_map: Optional[str] = None) -> str:
        resp = self._post_with_auth_retry(
            "/janitor/refactor",
            {"filename": filename, "content": content, "model": model, "provider": provider, "repo_map": repo_map},
        )

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Janitor failed")

        return data.get("output", "")

    def run_with_healing(
        self,
        command: List[str],
        cwd: str,
        model: str,
        provider: str,
        exit_code: int,
        stdout: str,
        stderr: str,
        context: Optional[dict] = None,
        repo_map: Optional[str] = None,
        healing_history: Optional[List[str]] = None,
    ) -> str:
        payload = {
            "command": command,
            "cwd": cwd,
            "model": model,
            "provider": provider,
            "exit_code": exit_code,
            "stdout": stdout or "",
            "stderr": stderr or "",
            "context": context or {},
            "repo_map": repo_map,
            "healing_history": healing_history or [],
        }

        tried: List[str] = []
        last_error: Optional[str] = None

        for path in ("/healer/run", "/healer"):
            tried.append(path)
            resp = self._post_with_auth_retry(path, payload)

            if resp.status_code == 401:
                raise RuntimeError("Unauthorized. Run: login")

            if resp.status_code == 404:
                last_error = f"404 on {path}"
                continue

            data = self._parse_json(resp)

            if not data.get("success"):
                raise RuntimeError(data.get("error") or "Healer failed")

            return data.get("output", "") or ""

        raise RuntimeError(
            f"Healer endpoint not found. Tried: {', '.join(tried)}. Last error: {last_error or 'unknown'}"
        )
    
    def search_asset(
        self,
        search_query: str,
        prompt: str,
        orientation: str,
        width: int,
        height: int,
        domain_tag: Optional[str] = None,
        image_type: Optional[str] = None,
        alt_text: Optional[str] = None,
        asset_format: str = "image",
        theme: Optional[str] = None
    ) -> str:
        """Calls NOVA API to securely get a stock photo, video, or AI-generated image URL."""
        payload = {
            "search_query": search_query,
            "prompt": prompt,
            "orientation": orientation,
            "width": width,
            "height": height,
            "domain_tag": domain_tag,
            "image_type": image_type,
            "alt_text": alt_text,
            "asset_format": asset_format,
            "theme": theme
        }
        resp = self._post_with_auth_retry("/assets/search", payload)
        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")
        data = self._parse_json(resp)
        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Asset search failed")
        return data.get("image_url", "")

    def embed_texts(self, texts: List[str]) -> List[List[float]]:
        """Calls NOVA_API to compute embeddings for texts, used for local semantic memory search."""
        resp = self._post_with_auth_retry("/memory/embed", {"texts": texts})

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Embedding request failed")

        return data.get("embeddings") or []

    def create_github_repo(
        self,
        repo_name: str,
        private: bool = False,
        description: Optional[str] = None,
    ) -> Dict[str, Any]:
            """
            Calls NOVA_API to create a GitHub repository for the authenticated user.
            """

            payload = {
                "repo_name": repo_name,
                "private": private,
                "description": description,
            }

            resp = self._post_with_auth_retry("/github/create-repo", payload)

            if resp.status_code == 401:
                raise RuntimeError("Unauthorized. Run: login")

            data = self._parse_json(resp)

            if not data.get("ok"):
                raise RuntimeError(data.get("detail") or "GitHub repo creation failed")

            return data
    def chat_stream(
        self,
        prompt: str,
        context: Optional[Dict[str, str]],
        model: str,
        provider: str,
        repo_map: Optional[str] = None,
        active_file: Optional[str] = None,
    ) -> Iterator[Dict[str, Any]]:
        """
        Calls NOVA_API streaming chat endpoint and yields SSE events.
        """
        payload = {
            "prompt": prompt,
            "context": context or {},
            "model": model,
            "provider": provider,
            "repo_map": repo_map,
            "active_file": active_file
        }

        self._require_auth()
        url = f"{self.base_url}/chat/stream"

        try:
            resp = requests.post(
                url,
                json=payload,
                headers=self._headers(),
                timeout=self.timeout,
                stream=True,
            )
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError("Build stream interrupted. Please check your internet connection.")
        except requests.RequestException:
            raise RuntimeError("Network failure during build. Check your internet and try again.")

        if resp.status_code == 401:
            if self._try_refresh():
                try:
                    resp = requests.post(
                        url,
                        json=payload,
                        headers=self._headers(),
                        timeout=self.timeout,
                        stream=True,
                    )
                except requests.RequestException as e:
                    raise RuntimeError(f"Streaming API request failed after refresh: {e}")

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        if resp.status_code >= 400:
            # Map technical status codes to clean human messages
            if resp.status_code == 403:
                msg = "Access denied. Please check your internet connection or login status."
            elif resp.status_code in (500, 502, 503, 504):
                msg = "The server is currently unreachable. Please check your internet connection and try again."
            elif resp.status_code == 401:
                msg = "Session expired. Please run 'nova login' to re-authenticate."
            elif resp.status_code == 402:
                msg = "Usage limit reached. Please upgrade your plan by visiting [bold cyan][link=https://nova.bridgeye.com/plans]https://nova.bridgeye.com/plans[/link][/bold cyan]"
            else:
                msg = "A connection error occurred. Please try again shortly."
            
            # Raise only the clean message, exposing no codes or JSON
            raise RuntimeError(msg)

        try:
            for raw_line in resp.iter_lines(decode_unicode=True):
                if not raw_line:
                    continue

                line = raw_line.strip()
                if not line.startswith("data: "):
                    continue

                data_str = line[len("data: "):].strip()
                if not data_str:
                    continue

                try:
                    yield json.loads(data_str)
                except Exception:
                    yield {"type": "error", "error": f"Invalid stream payload: {data_str}"}
        except (requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError):
            raise RuntimeError("Connection dropped during streaming. Please check your internet connection and try again.")


--- FILE: nova_core/ai/utils.py ---

# nova_cli/nova_core/ai/utils.py
# CLI-only list for model selector UI.
# The API remains the source of truth for what is actually allowed.

MODELS = {
    "groq": [
        "openai/gpt-oss-20b",
        "meta-llama/llama-4-scout-17b-16e-instruct",
        "llama-3.1-8b-instant",
    ],
    "openrouter": [
        "openai/gpt-oss-120b",
    ]
}

--- FILE: nova_core/auth/__init__.py ---



--- FILE: nova_core/auth/client.py ---

# NOVA_CLI\nova_cli\nova_core\auth\client.py

import os
import time
import webbrowser
import requests
from typing import Optional, Dict, Any


class NovaAuthClient:
    def __init__(self, base_url: Optional[str] = None):
        # Priority:
        # 1) passed base_url
        # 2) env NOVA_AUTH_BASE_URL
        # 3) default production
        resolved = base_url or os.getenv("NOVA_AUTH_BASE_URL") or "https://nova.bridgeye.com"
        self.base_url = resolved.rstrip("/")

        # Basic client identity (helps audit logs + debugging)
        self.user_agent = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")

    def _headers(self) -> Dict[str, str]:
        return {"User-Agent": self.user_agent}

    def create_session(self) -> str:
        # Render can cold-start, keep this lenient
        r = requests.post(
            f"{self.base_url}/auth/session",
            timeout=30,
            headers=self._headers(),
        )
        r.raise_for_status()
        data = r.json()
        return data["session_id"]

    def open_browser(self, session_id: str) -> None:
        url = f"{self.base_url}/login?session_id={session_id}"
        webbrowser.open(url)

    def poll_session(self, session_id: str, timeout: int = 300) -> Optional[str]:
        start = time.time()

        while time.time() - start < timeout:
            try:
                r = requests.get(
                    f"{self.base_url}/auth/session/{session_id}",
                    timeout=10,
                    headers=self._headers(),
                )

                if r.status_code == 200:
                    data = r.json()
                    
                    if data.get("status") == "approved":
                        return data.get("auth_code")
                        
                    # --- NEW: Catch rejected sessions ---
                    if data.get("status") == "rejected":
                        raise PermissionError(data.get("reason", "Login rejected by server. VPN Detected."))

            except PermissionError:
                raise  # Bubble this specific error up to the handler
            except Exception:
                pass

            time.sleep(2)

        return None

    def exchange_auth_code(self, auth_code: str) -> Dict[str, Any]:
        try:
            r = requests.post(
                f"{self.base_url}/auth/token",
                json={"auth_code": auth_code},
                timeout=20,
                headers=self._headers(),
            )

            if r.status_code != 200:
                return {
                    "error": "token_exchange_failed",
                    "status": r.status_code,
                    "body": r.text,
                }

            return r.json()

        except Exception as e:
            return {"error": "request_failed", "body": str(e)}

    def refresh_access(self, refresh_token: str) -> Dict[str, Any]:
        try:
            r = requests.post(
                f"{self.base_url}/auth/refresh",
                json={"refresh_token": refresh_token},
                timeout=20,
                headers=self._headers(),
            )

            if r.status_code != 200:
                return {
                    "error": "refresh_failed",
                    "status": r.status_code,
                    "body": r.text,
                }

            return r.json()

        except Exception as e:
            return {"error": "request_failed", "body": str(e)}


--- FILE: nova_core/auth/storage.py ---

# NOVA_CLI\nova_cli\nova_core\auth\storage.py

import os
import json
import time
from typing import Optional, Dict, Any

AUTH_DIR = os.path.expanduser("~/.nova")
AUTH_FILE = os.path.join(AUTH_DIR, "auth.json")

DEFAULT_EXPIRES_IN = 3600


def _now() -> float:
    return time.time()


def _read_json(path: str) -> Optional[Dict[str, Any]]:
    if not os.path.exists(path):
        return None
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def save_auth(data: dict) -> None:
    os.makedirs(AUTH_DIR, exist_ok=True)

    # Normalize issued_at
    if "issued_at" not in data or data["issued_at"] in (None, ""):
        data["issued_at"] = _now()
    else:
        try:
            data["issued_at"] = float(data["issued_at"])
        except Exception:
            data["issued_at"] = _now()

    # Normalize expires_in
    if "expires_in" in data and data["expires_in"] not in (None, ""):
        try:
            data["expires_in"] = int(data["expires_in"])
        except Exception:
            data["expires_in"] = DEFAULT_EXPIRES_IN
    else:
        # if missing, keep whatever is there; access expiry will be treated conservatively
        data.setdefault("expires_in", DEFAULT_EXPIRES_IN)

    with open(AUTH_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


def load_auth() -> Optional[Dict[str, Any]]:
    return _read_json(AUTH_FILE)


def logout() -> None:
    if os.path.exists(AUTH_FILE):
        os.remove(AUTH_FILE)


def has_refresh_token() -> bool:
    data = load_auth()
    return bool(data and data.get("refresh_token"))


def _access_expired(data: Dict[str, Any]) -> bool:
    access = data.get("access_token")
    if not access:
        return True

    expires_in = data.get("expires_in")
    issued_at = data.get("issued_at")

    # If metadata missing, treat as expired so client will refresh safely.
    if expires_in in (None, "", 0) or issued_at in (None, ""):
        return True

    try:
        exp_ts = float(issued_at) + float(expires_in)
    except Exception:
        return True

    return _now() >= exp_ts


def is_logged_in() -> bool:
    """
    Strict check: True only if valid token strings exist.
    """
    data = load_auth()
    if not data or not isinstance(data, dict):
        return False

    has_access = bool(data.get("access_token"))
    has_refresh = bool(data.get("refresh_token"))

    if has_refresh:
        return True
    
    return has_access and not _access_expired(data)


def get_access_token() -> Optional[str]:
    data = load_auth()
    if not data:
        return None

    if _access_expired(data):
        return None

    return data.get("access_token")


def get_refresh_token() -> Optional[str]:
    data = load_auth()
    if not data:
        return None
    return data.get("refresh_token")


def update_tokens(
    access_token: str,
    refresh_token: str,
    expires_in: int = DEFAULT_EXPIRES_IN,
    issued_at: Optional[float] = None,
) -> None:
    data = load_auth() or {}
    data["access_token"] = access_token
    data["refresh_token"] = refresh_token
    data["expires_in"] = int(expires_in) if expires_in else DEFAULT_EXPIRES_IN
    data["issued_at"] = float(issued_at) if issued_at else _now()
    save_auth(data)


def update_access_token(
    access_token: str,
    expires_in: int = DEFAULT_EXPIRES_IN,
    issued_at: Optional[float] = None,
) -> None:
    data = load_auth() or {}
    data["access_token"] = access_token
    data["expires_in"] = int(expires_in) if expires_in else DEFAULT_EXPIRES_IN
    data["issued_at"] = float(issued_at) if issued_at else _now()
    save_auth(data)
