"""Boris prompts - planning and prompt crafting (merged from planner + crafter)."""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime

# Force unbuffered stdout for real-time output on Windows
os.environ.setdefault("PYTHONUNBUFFERED", "1")

from dag import DependencyError, validate_plan_dependencies
from state import Milestone, Plan, UIMilestone, UIPlan, E2EMilestone, E2EPlan

IS_WINDOWS = sys.platform == "win32"
logger = logging.getLogger(__name__)

# Resolve claude command to full path so shell=False works on Windows (.cmd files)
CLAUDE_CMD = shutil.which("claude") or "claude"

# Import embedded prompt data directly (no file I/O needed)
from boris_prompt_data import BORIS_PROMPT as _EMBEDDED_BORIS_PROMPT

import os as _os

REASONING_MODEL_PATH = _os.environ.get("REASONING_MODEL_PATH", "")

def _get_milestone_sketch(task: str, file_listing: str) -> str:
    """Use fine-tuned model to generate a rough milestone sketch."""
    if not REASONING_MODEL_PATH or not _os.path.isdir(REASONING_MODEL_PATH):
        return ""
    try:
        from transformers import AutoModelForCausalLM, AutoTokenizer
        import torch
        tokenizer = AutoTokenizer.from_pretrained(REASONING_MODEL_PATH)
        model = AutoModelForCausalLM.from_pretrained(
            REASONING_MODEL_PATH, torch_dtype=torch.bfloat16, device_map="auto"
        )
        prompt = (
            f"Break this task into ordered milestones for a build agent:\n"
            f"Task: {task}\n"
            f"Existing files:\n{file_listing}\n"
            f"List milestones with titles and dependencies."
        )
        messages = [{"role": "user", "content": prompt}]
        input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
        inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
        with torch.no_grad():
            output = model.generate(**inputs, max_new_tokens=512, temperature=0.7, do_sample=True)
        sketch = tokenizer.decode(output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
        return sketch
    except Exception:
        return ""

# Cached Boris system prompt
_boris_prompt_cache = None


def resolve_greenfield_project_dir(task: str, base_dir: str) -> str | None:
    """Detect if the task implies creating a new (greenfield) project and extract the project name.

    Uses a quick Claude call to determine if the task is about creating a new project
    from scratch. If so, returns the resolved absolute path for the new project directory
    inside base_dir (typically the All Folders directory).
    Returns None if the task is about an existing project or doesn't imply a new folder.
    """
    prompt = (
        "You are a project-name extractor. Given a task description, determine if it asks to "
        "CREATE a NEW project/app/site from scratch (greenfield). If yes, extract a short, "
        "filesystem-safe project name (lowercase, hyphens, no spaces). If the task is about "
        "modifying, fixing, or extending an EXISTING project, return NONE.\n\n"
        "Rules:\n"
        "- Only return a name if the task clearly implies creating something NEW that doesn't exist yet.\n"
        "- The name should be concise (1-3 words joined by hyphens).\n"
        "- If the user specifies a project name explicitly, use that (sanitized).\n"
        "- If the user says 'called X' or 'named X', use X.\n"
        "- Return ONLY the project name or NONE, nothing else. No explanation.\n\n"
        f"Task: {task}\n\n"
        f"Base directory for new projects: {base_dir}\n\n"
        "Project name (or NONE):"
    )
    try:
        env = os.environ.copy()
        env["PYTHONUNBUFFERED"] = "1"
        result = subprocess.run(
            [CLAUDE_CMD, "-p", "--no-session-persistence"],
            input=prompt,
            capture_output=True,
            timeout=60,
            env=env,
            encoding="utf-8",
            errors="replace",
        )
        if result.returncode != 0:
            return None
        answer = result.stdout.strip().lower()
        # Strip markdown formatting if Claude wraps it
        answer = re.sub(r"[`*_\"\']", "", answer).strip()
        if not answer or answer == "none" or len(answer) > 60:
            return None
        # Sanitize: only allow alphanumeric, hyphens, underscores
        sanitized = re.sub(r"[^a-z0-9_-]", "-", answer).strip("-")
        if not sanitized:
            return None
        return os.path.join(base_dir, sanitized)
    except Exception as e:
        logger.warning("Greenfield project detection failed: %s", e)
        return None


def _load_boris_prompt() -> str:
    """Return Boris's management prompt from embedded data. Cached after first call."""
    global _boris_prompt_cache
    if _boris_prompt_cache is not None:
        return _boris_prompt_cache

    _boris_prompt_cache = _EMBEDDED_BORIS_PROMPT.strip()
    logger.debug("Loaded Boris prompt from embedded boris_prompt_data.py (%d chars)", len(_boris_prompt_cache))
    return _boris_prompt_cache

# Regex to strip ANSI escape codes
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")


def _clean_output(text: str) -> str:
    """Strip ANSI escape codes from text for safe embedding in prompts."""
    return _ANSI_RE.sub("", text)


def _list_project_files(project_dir: str, max_files: int = 500) -> str:
    """Walk project_dir and return formatted file listing.

    Caps output at *max_files* entries to keep prompts within Claude CLI limits.
    """
    skip_dirs = {".git", "__pycache__", "node_modules", ".boris", ".venv", "venv"}
    file_list = []
    truncated = False

    for root, dirs, files in os.walk(project_dir):
        # Filter out hidden and skip dirs
        dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
        rel_root = os.path.relpath(root, project_dir)
        for fname in sorted(files):
            if len(file_list) >= max_files:
                truncated = True
                break
            if rel_root == ".":
                file_list.append(fname)
            else:
                file_list.append(os.path.join(rel_root, fname))
        if truncated:
            break

    if not file_list:
        return "(empty project)"

    result = "\n".join(f"  {f}" for f in file_list)
    if truncated:
        result += f"\n  ... (truncated — showing {max_files} of many files)"
    return result


# --- Planning (from planner.py) ---


def _extract_json_array(response: str) -> str:
    """Extract a JSON array from a Claude response that may contain markdown fences or extra text.

    Tries multiple strategies in order:
    1. Extract from markdown code fences (```json ... ```)
    2. Find raw JSON array by bracket matching
    3. Return the stripped response as-is (let json.loads produce the error)
    """
    # Strategy 1: markdown code fences
    json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
    if json_match:
        extracted = json_match.group(1).strip()
        if extracted:
            return extracted

    # Strategy 2: find the outermost JSON array by matching brackets
    # This handles cases where there's text before/after the JSON
    start = response.find("[")
    if start != -1:
        depth = 0
        for i in range(start, len(response)):
            if response[i] == "[":
                depth += 1
            elif response[i] == "]":
                depth -= 1
                if depth == 0:
                    return response[start:i + 1]

    # Strategy 3: return as-is
    return response.strip()


def create_plan(task: str, project_dir: str, turbo: bool = False) -> Plan:
    """Break a task into milestones using Claude CLI.

    Args:
        task: The task description.
        project_dir: Path to the project directory.
        turbo: When True, emphasize minimal dependencies and maximum parallelism.
    """
    file_listing = _list_project_files(project_dir)

    if turbo:
        dependency_instructions = (
            "CRITICAL DEPENDENCY RULES (turbo/parallel mode):\n"
            "- Only add a dependency if the milestone TRULY requires output from another milestone to function.\n"
            "- Two milestones that share a common dependency should NOT depend on each other unless one genuinely needs the other's output.\n"
            "- Maximize parallelism in the dependency graph. Independent features, pages, or modules that share the same foundation should run in parallel.\n"
            "- Do NOT chain milestones linearly unless there is a real data/code dependency between them.\n\n"
            "Example of WRONG dependencies (overly sequential):\n"
            '  M1: Project Setup (depends_on: [])\n'
            '  M2: Auth Backend (depends_on: ["M1"])\n'
            '  M3: Login Page (depends_on: ["M2"])\n'
            '  M4: Dashboard Page (depends_on: ["M3"])  <-- WRONG: only needs M2, not M3\n'
            '  M5: Profile Page (depends_on: ["M4"])    <-- WRONG: only needs M2, not M4\n\n'
            "Example of CORRECT dependencies (maximizes parallelism):\n"
            '  M1: Project Setup (depends_on: [])\n'
            '  M2: Auth Backend (depends_on: ["M1"])\n'
            '  M3: Login Page (depends_on: ["M2"])\n'
            '  M4: Dashboard Page (depends_on: ["M2"])  <-- parallel with M3\n'
            '  M5: Profile Page (depends_on: ["M2"])    <-- parallel with M3, M4\n\n'
        )
    else:
        dependency_instructions = (
            "Order by dependency: foundation first, then features that depend on it.\n"
        )

    milestone_sketch = _get_milestone_sketch(task, file_listing)
    sketch_section = (
        f"\n\nA smaller model generated this rough milestone sketch as a starting point. "
        f"Refine and improve it, ensuring proper JSON format:\n{milestone_sketch}\n"
    ) if milestone_sketch else ""

    prompt = (
        "You are a technical project planner. Break this task into ordered milestones.\n"
        "Each milestone must be a concrete, buildable unit that DaveLoop can execute independently.\n"
        "IMPORTANT: All project files MUST be created inside the project directory specified below.\n"
        "Do NOT create files outside this directory.\n\n"
        f"{dependency_instructions}\n"
        f"Task: {task}\n"
        f"Project directory: {project_dir}\n"
        f"Existing files:\n{file_listing}\n"
        f"{sketch_section}\n"
        "Return ONLY a JSON array (no other text) of milestones:\n"
        "[\n"
        "  {\n"
        '    "id": "M1",\n'
        '    "title": "Short title",\n'
        '    "description": "Detailed description of what to build",\n'
        '    "depends_on": [],\n'
        '    "acceptance_criteria": ["criterion 1", "criterion 2"],\n'
        '    "files_to_create": ["file1.py"],\n'
        '    "files_to_modify": []\n'
        "  }\n"
        "]"
    )

    max_plan_parse_attempts = 3

    for parse_attempt in range(max_plan_parse_attempts):
        try:
            env = os.environ.copy()
            env["PYTHONIOENCODING"] = "utf-8"
            env.pop("CLAUDECODE", None)

            import sys as _sys
            import threading
            import time

            attempt_label = f" (attempt {parse_attempt + 1}/{max_plan_parse_attempts})" if parse_attempt > 0 else ""
            print(f"  [Boris] Planning phase{attempt_label} - Claude is thinking...", flush=True)
            print(flush=True)

            # Run Claude CLI in a thread so we can show a heartbeat while waiting
            plan_result = {"stdout": "", "returncode": None, "error": None}

            def _run_claude():
                try:
                    # Use shell=False for reliable stdin piping on Windows.
                    # shell=True routes through cmd.exe which can mangle special chars.
                    cmd = [CLAUDE_CMD, "-p", "--no-session-persistence"]
                    result = subprocess.run(
                        cmd,
                        input=prompt,
                        capture_output=True,
                        timeout=600,
                        env=env,
                        encoding="utf-8",
                        errors="replace",
                    )
                    plan_result["stdout"] = result.stdout
                    plan_result["returncode"] = result.returncode
                    if result.returncode != 0:
                        # Capture both stderr and stdout for diagnostics -
                        # Claude CLI may write errors to either stream.
                        err = result.stderr.strip()
                        if not err:
                            err = result.stdout.strip()[:500]
                        plan_result["error"] = err
                except subprocess.TimeoutExpired:
                    plan_result["error"] = "timeout"
                except FileNotFoundError:
                    plan_result["error"] = "not_found"

            thread = threading.Thread(target=_run_claude, daemon=True)
            thread.start()

            # Heartbeat: show Boris is alive every 10 seconds
            elapsed = 0
            spinner = ["|", "/", "-", "\\"]
            while thread.is_alive():
                idx = (elapsed // 2) % len(spinner)
                _sys.stdout.write(f"\r  [Boris] {spinner[idx]} Claude is planning... ({elapsed}s)")
                _sys.stdout.flush()
                time.sleep(2)
                elapsed += 2

            # Clear spinner line
            _sys.stdout.write("\r" + " " * 60 + "\r")
            _sys.stdout.flush()

            # Handle errors
            if plan_result["error"] == "timeout":
                raise subprocess.TimeoutExpired(CLAUDE_CMD, 600)
            if plan_result["error"] == "not_found":
                raise FileNotFoundError(CLAUDE_CMD)
            if plan_result["returncode"] != 0:
                raise RuntimeError(f"Claude CLI failed (exit {plan_result['returncode']}): {plan_result['error']}")

            response = plan_result["stdout"].strip()
            logger.debug("Claude raw response (%d chars): %s", len(response), response[:500])

            # Guard against empty response (token limit hit, API error, etc.)
            if not response:
                raise json.JSONDecodeError("Empty response from Claude CLI", "", 0)

            # Parse milestones from response and show them live
            print("  [Boris] Plan received! Parsing milestones...", flush=True)

            # Show milestone IDs/titles as we find them in the JSON
            for id_match in re.finditer(r'"id"\s*:\s*"(M\d+)"', response):
                print(f"  [Boris]   Found milestone {id_match.group(1)}", flush=True)
            print(flush=True)

            # Extract JSON from response - handle markdown code blocks and surrounding text
            json_str = _extract_json_array(response)

            milestones_data = json.loads(json_str)

            milestones = [
                Milestone(
                    id=m["id"],
                    title=m["title"],
                    description=m["description"],
                    depends_on=m.get("depends_on", []),
                    acceptance_criteria=m.get("acceptance_criteria", []),
                    files_to_create=m.get("files_to_create", []),
                    files_to_modify=m.get("files_to_modify", []),
                )
                for m in milestones_data
            ]

            plan = Plan(task=task, milestones=milestones)

            # Validate dependency graph (catches cycles and missing refs early)
            try:
                validate_plan_dependencies(plan)
            except DependencyError as e:
                logger.warning("Plan dependency validation failed: %s", e)
                if parse_attempt < max_plan_parse_attempts - 1:
                    print(f"  [Boris] Dependency error in plan: {e}. Retrying...", flush=True)
                    continue
                raise RuntimeError(f"Plan has invalid dependencies: {e}") from e

            # Save plan as markdown
            plan_path = _save_plan_markdown(plan, task, project_dir)
            logger.info("Plan saved to %s", plan_path)

            print(f"  [Boris] Plan saved to {plan_path}", flush=True)

            return plan

        except json.JSONDecodeError as e:
            if parse_attempt < max_plan_parse_attempts - 1:
                print(f"  [Boris] JSON parse failed: {e}. Retrying plan generation...", flush=True)
                logger.warning("Plan JSON parse failed (attempt %d): %s", parse_attempt + 1, e)
                continue
            raise RuntimeError(f"Failed to parse Claude plan response as JSON after {max_plan_parse_attempts} attempts: {e}") from e
        except subprocess.TimeoutExpired:
            raise RuntimeError("Claude CLI timed out while generating plan")
        except FileNotFoundError:
            raise RuntimeError(
                f"'{CLAUDE_CMD}' command not found. Is Claude CLI installed?"
            )

    # Should not reach here, but just in case
    raise RuntimeError("Failed to create plan after all attempts")


def _save_plan_markdown(plan: Plan, task: str, project_dir: str) -> str:
    """Save a human-readable markdown version of the plan. Returns filepath."""
    plans_dir = os.path.join(project_dir, ".boris", "plans")
    os.makedirs(plans_dir, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"plan_{timestamp}.md"
    filepath = os.path.join(plans_dir, filename)

    lines = [
        f"# Boris Plan",
        f"",
        f"**Task:** {task}",
        f"**Created:** {plan.created_at}",
        f"**Milestones:** {len(plan.milestones)}",
        f"",
        f"---",
        f"",
    ]

    for m in plan.milestones:
        lines.append(f"## {m.id}: {m.title}")
        lines.append(f"")
        lines.append(f"{m.description}")
        lines.append(f"")
        if m.depends_on:
            lines.append(f"**Depends on:** {', '.join(m.depends_on)}")
            lines.append(f"")
        lines.append(f"**Acceptance Criteria:**")
        for c in m.acceptance_criteria:
            lines.append(f"- {c}")
        lines.append(f"")
        if m.files_to_create:
            lines.append(f"**Files to create:** {', '.join(m.files_to_create)}")
        if m.files_to_modify:
            lines.append(f"**Files to modify:** {', '.join(m.files_to_modify)}")
        lines.append(f"")
        lines.append(f"---")
        lines.append(f"")

    with open(filepath, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))

    # Also generate Mermaid diagram at a fixed, well-known path
    _save_mermaid_diagram(plan, project_dir)

    return filepath


def _save_mermaid_diagram(plan: Plan, project_dir: str) -> str:
    """Save a Mermaid flowchart diagram of the plan to .boris/plan_diagram.md.

    This file lives at a fixed path so agents can read it directly without
    globbing or searching. It shows milestones, dependencies, and statuses.
    Returns the filepath.
    """
    boris_dir = os.path.join(project_dir, ".boris")
    os.makedirs(boris_dir, exist_ok=True)
    filepath = os.path.join(boris_dir, "plan_diagram.md")

    status_label = {
        "completed": "done",
        "in_progress": "active",
        "skipped": "skipped",
        "pending": "pending",
    }

    lines = []
    lines.append("# Boris Plan — Dependency Diagram")
    lines.append("")
    lines.append(f"**Task:** {plan.task}")
    lines.append(f"**Generated:** {datetime.now().isoformat()}")
    lines.append("")
    lines.append("```mermaid")
    lines.append("flowchart TD")

    # Define nodes with status labels
    for m in plan.milestones:
        label = f"{m.id}: {m.title}"
        sl = status_label.get(getattr(m, "status", "pending"), "pending")
        lines.append(f'    {m.id}["{label}<br/>({sl})"]')

    lines.append("")

    # Define edges from dependencies
    for m in plan.milestones:
        for dep in m.depends_on:
            lines.append(f"    {dep} --> {m.id}")

    lines.append("")

    # Style nodes by status
    completed = [m.id for m in plan.milestones if getattr(m, "status", "pending") == "completed"]
    in_progress = [m.id for m in plan.milestones if getattr(m, "status", "pending") == "in_progress"]
    skipped = [m.id for m in plan.milestones if getattr(m, "status", "pending") == "skipped"]
    pending = [m.id for m in plan.milestones if getattr(m, "status", "pending") == "pending"]

    if completed:
        lines.append(f"    classDef done fill:#2d6a2d,stroke:#1a4a1a,color:#fff")
        lines.append(f"    class {','.join(completed)} done")
    if in_progress:
        lines.append(f"    classDef active fill:#b8860b,stroke:#8b6508,color:#fff")
        lines.append(f"    class {','.join(in_progress)} active")
    if skipped:
        lines.append(f"    classDef skip fill:#666,stroke:#444,color:#fff")
        lines.append(f"    class {','.join(skipped)} skip")
    if pending:
        lines.append(f"    classDef pend fill:#4a6fa5,stroke:#365880,color:#fff")
        lines.append(f"    class {','.join(pending)} pend")

    lines.append("```")
    lines.append("")

    # Add a quick-reference table for agents
    lines.append("## Milestone Summary")
    lines.append("")
    lines.append("| ID | Title | Status | Depends On | Files |")
    lines.append("|---|---|---|---|---|")
    for m in plan.milestones:
        deps = ", ".join(m.depends_on) if m.depends_on else "—"
        files = ", ".join((m.files_to_create or []) + (m.files_to_modify or [])) or "—"
        st = getattr(m, "status", "pending")
        lines.append(f"| {m.id} | {m.title} | {st} | {deps} | {files} |")
    lines.append("")

    with open(filepath, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))

    return filepath


def load_plan_from_file(filepath: str) -> Plan:
    """Parse a saved plan markdown file back into a Plan object.

    Reads the markdown format produced by _save_plan_markdown and reconstructs
    the Plan with its Milestone objects.
    """
    if not os.path.exists(filepath):
        raise FileNotFoundError(f"Plan file not found: {filepath}")

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

    # Extract task from **Task:** line
    task_match = re.search(r"\*\*Task:\*\*\s*(.+)", content)
    task = task_match.group(1).strip() if task_match else "Unknown task"

    # Extract created_at from **Created:** line
    created_match = re.search(r"\*\*Created:\*\*\s*(.+)", content)
    created_at = created_match.group(1).strip() if created_match else datetime.now().isoformat()

    # Split into milestone sections by ## M<N>: headers
    milestone_sections = re.split(r"(?=^## M\d+:)", content, flags=re.MULTILINE)

    milestones = []
    for section in milestone_sections:
        # Only process sections that start with a milestone header
        header_match = re.match(r"^## (M\d+):\s*(.+)", section)
        if not header_match:
            continue

        m_id = header_match.group(1)
        m_title = header_match.group(2).strip()

        # Extract description: text between the header and the first **bold** field or ---
        # Skip the header line, then collect lines until we hit a **field:** or ---
        lines = section.split("\n")
        desc_lines = []
        in_desc = False
        for line in lines[1:]:  # skip header line
            stripped = line.strip()
            if not stripped:
                if in_desc:
                    desc_lines.append("")
                continue
            if stripped.startswith("**") or stripped == "---":
                break
            desc_lines.append(stripped)
            in_desc = True
        description = "\n".join(desc_lines).strip()

        # Extract depends_on
        depends_match = re.search(r"\*\*Depends on:\*\*\s*(.+)", section)
        depends_on = []
        if depends_match:
            deps_str = depends_match.group(1).strip()
            depends_on = [d.strip() for d in deps_str.split(",") if d.strip()]

        # Extract acceptance criteria (bulleted list after **Acceptance Criteria:**)
        criteria = []
        criteria_match = re.search(r"\*\*Acceptance Criteria:\*\*\s*\n((?:\s*- .+\n?)+)", section)
        if criteria_match:
            for line in criteria_match.group(1).strip().split("\n"):
                line = line.strip()
                if line.startswith("- "):
                    criteria.append(line[2:].strip())

        # Extract files to create
        files_create_match = re.search(r"\*\*Files to create:\*\*\s*(.+)", section)
        files_to_create = []
        if files_create_match:
            files_to_create = [f.strip() for f in files_create_match.group(1).split(",") if f.strip()]

        # Extract files to modify
        files_modify_match = re.search(r"\*\*Files to modify:\*\*\s*(.+)", section)
        files_to_modify = []
        if files_modify_match:
            files_to_modify = [f.strip() for f in files_modify_match.group(1).split(",") if f.strip()]

        milestones.append(Milestone(
            id=m_id,
            title=m_title,
            description=description,
            depends_on=depends_on,
            acceptance_criteria=criteria,
            files_to_create=files_to_create,
            files_to_modify=files_to_modify,
        ))

    if not milestones:
        raise RuntimeError(f"No milestones found in plan file: {filepath}")

    plan = Plan(task=task, milestones=milestones, created_at=created_at)

    # Validate dependency graph on loaded plans too
    validate_plan_dependencies(plan)

    return plan


# --- Prompt Crafting (from crafter.py) ---


def craft_prompt(milestone: Milestone, plan: Plan, project_dir: str,
                  turbo: bool = False, sibling_milestones: list[Milestone] | None = None,
                  progress_context: str = None) -> str:
    """Build a detailed prompt for DaveLoop to execute a milestone.

    This is the most critical function in Boris. DaveLoop needs everything
    in ONE prompt - context, spec, integration points, acceptance criteria.

    Args:
        milestone: The milestone to build a prompt for.
        plan: The full plan for context.
        project_dir: Path to the project directory.
        turbo: When True, adds parallel execution warnings.
        sibling_milestones: Other milestones running concurrently in the same turbo batch.
    """
    sections = []

    # SCOPE LOCK - tell DaveLoop exactly what to build, loud and clear
    sections.append(f"# YOUR TASK: {milestone.id} - {milestone.title}")
    sections.append("")
    sections.append(f"You are building ONE milestone only: **{milestone.id}: {milestone.title}**")
    sections.append("Do NOT build the entire project. Do NOT build other milestones.")
    sections.append(f"Build ONLY what is described below for {milestone.id}.")
    sections.append("")

    # Files scope - explicit
    all_files = (milestone.files_to_create or []) + (milestone.files_to_modify or [])
    if all_files:
        sections.append(f"**Files you may touch:** {', '.join(all_files)}")
        sections.append("**Do NOT create or modify any other files.**")
        sections.append("")

    # Turbo mode: parallel execution warning
    if turbo and sibling_milestones:
        sections.append("## PARALLEL EXECUTION WARNING")
        sections.append("")
        sections.append("**You are running in PARALLEL with other DaveLoops.** Other milestones")
        sections.append("are being built AT THE SAME TIME by sibling DaveLoop processes.")
        sections.append("Modifying files outside your scope WILL cause conflicts or silent overwrites.")
        sections.append("")
        sections.append("**Sibling milestones running concurrently:**")
        for sib in sibling_milestones:
            sib_files = (sib.files_to_create or []) + (sib.files_to_modify or [])
            sib_files_str = ", ".join(sib_files) if sib_files else "(no specific files)"
            sections.append(f"- **{sib.id}: {sib.title}** - Files: {sib_files_str}")
        sections.append("")
        # Collect all sibling files into a flat list for the explicit prohibition
        all_sibling_files = []
        for sib in sibling_milestones:
            all_sibling_files.extend(sib.files_to_create or [])
            all_sibling_files.extend(sib.files_to_modify or [])
        if all_sibling_files:
            unique_sibling_files = sorted(set(all_sibling_files))
            sections.append("**DO NOT touch these files (owned by sibling milestones):**")
            for sf in unique_sibling_files:
                sections.append(f"- {sf}")
            sections.append("")
        sections.append("**Rules:**")
        sections.append("1. ONLY modify files listed in YOUR files_to_create/files_to_modify scope above.")
        sections.append("2. Do NOT modify shared config files (package.json, routing tables, etc.) unless they are explicitly in YOUR scope.")
        sections.append("3. If you need a shared file changed, add a TODO comment instead of editing it.")
        sections.append("4. File locks are enforced: if you encounter a lock conflict, wait and retry rather than forcing writes.")
        sections.append("")

    # Learnings from previous milestones (Ralph-style progress context)
    if progress_context:
        sections.append("## Learnings from Previous Milestones")
        sections.append(progress_context)
        sections.append("")

    # What to build
    sections.append(f"## What to Build")
    sections.append(milestone.description)
    sections.append("")

    # Files to Create
    if milestone.files_to_create:
        sections.append("## Files to Create")
        for f in milestone.files_to_create:
            sections.append(f"- {f}")
        sections.append("")

    # Files to Modify
    if milestone.files_to_modify:
        sections.append("## Files to Modify")
        for f in milestone.files_to_modify:
            sections.append(f"- {f}")
        sections.append("")

    # Acceptance Criteria - right after the spec, before context
    sections.append("## Acceptance Criteria")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    # Verification — mandatory end-to-end test gate
    sections.append("## Verification")
    if milestone.test_commands:
        sections.append("MANDATORY END-TO-END TEST — YOU ARE NOT DONE UNTIL THESE COMMANDS PASS:")
        sections.append("")
        for i, cmd in enumerate(milestone.test_commands, 1):
            sections.append(f"{i}. `{cmd}`")
        sections.append("")
        sections.append("Run each command above. If ANY command fails, errors, or produces unexpected output — "
                        "the milestone is NOT complete. Debug, fix, and re-run. "
                        "Do NOT declare [DAVELOOP:RESOLVED] based on syntax checks alone.")
    else:
        sections.append("After implementation, verify by:")
        for criterion in milestone.acceptance_criteria:
            sections.append(f"- {criterion}")
        sections.append("When complete, ensure the code runs without errors.")
    sections.append("")

    # Context section - AFTER the task spec so DaveLoop reads the task first
    sections.append("---")
    sections.append("## Background Context (for reference only - do NOT build all of this)")
    sections.append(f"Overall project: {plan.task}")
    sections.append(f"Project directory: {project_dir}")
    sections.append("")

    # What Already Exists
    completed = [m for m in plan.milestones if m.status == "completed"]
    if completed:
        sections.append("### Completed Milestones")
        for m in completed:
            files = m.files_to_create + m.files_to_modify
            file_str = ", ".join(files) if files else "(no specific files)"
            sections.append(f"- {m.id}: {m.title} - Files: {file_str}")
        sections.append("")

    # Current file listing
    sections.append("### Current Project Files")
    file_tree = _list_project_files(project_dir)
    sections.append(file_tree)
    sections.append("")

    # Integration Points
    if milestone.depends_on:
        sections.append("### Integration Points")
        for dep_id in milestone.depends_on:
            dep = next((m for m in plan.milestones if m.id == dep_id), None)
            if dep:
                sections.append(
                    f"- {dep.id}: {dep.title} - "
                    f"Files: {', '.join(dep.files_to_create) if dep.files_to_create else 'none'}. "
                    f"Integrate with this existing code."
                )
        sections.append("")

    # Auto-attach relevant skills based on milestone content
    try:
        from skill_inject import inject_skills, get_matched_skill_names
        skill_text = f"{milestone.title} {milestone.description} {' '.join(milestone.acceptance_criteria)}"
        skills_block = inject_skills(skill_text, agent="boris")
        if skills_block:
            sections.append(skills_block)
            skill_names = get_matched_skill_names(skill_text, agent="boris")
            if skill_names:
                print(f"  [Boris] Skills: {skill_names}", flush=True)
    except ImportError:
        pass  # skill_inject not available — continue without skills

    # Final reminder
    sections.append("---")
    sections.append(f"**REMINDER: Build ONLY {milestone.id}: {milestone.title}. Nothing else.**")

    return "\n".join(sections)


def craft_correction(output: str, milestone: Milestone, plan: Plan, reason: str,
                     progress_context: str = None) -> str:
    """Build a correction prompt for DaveLoop after a failed or off-plan attempt."""
    sections = []

    sections.append(f"## Correction Required: {milestone.title}")
    sections.append("")
    sections.append(f"The previous attempt to build '{milestone.title}' had issues.")
    sections.append(f"**Problem:** {reason}")
    sections.append("")

    # Learnings from previous milestones (Ralph-style progress context)
    if progress_context:
        sections.append("## Learnings from Previous Milestones")
        sections.append(progress_context)
        sections.append("")

    # Output tail (last 200 lines), cleaned of ANSI codes
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    tail = output_lines[-200:] if len(output_lines) > 200 else output_lines
    sections.append("## Output from Previous Attempt (last 200 lines)")
    sections.append("```")
    sections.append("\n".join(tail))
    sections.append("```")
    sections.append("")

    sections.append("Please fix the issues and complete the milestone.")
    sections.append("")

    # Include same context as craft_prompt
    sections.append(f"## Project Context")
    sections.append(f"You are working on: {plan.task}")
    sections.append(f"Project directory: {plan.task}")
    sections.append("")

    # Current Milestone
    sections.append(f"## Current Milestone: {milestone.title}")
    sections.append(milestone.description)
    sections.append("")

    # Acceptance Criteria
    sections.append("## Acceptance Criteria")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    # Boundaries
    sections.append("## Boundaries")
    sections.append("Do NOT modify files outside this milestone's scope.")
    sections.append("Do NOT refactor or 'improve' existing working code.")
    sections.append(f"Focus ONLY on: {milestone.title}")
    sections.append("")

    # Verification
    sections.append("## Verification")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("When complete, ensure the code runs without errors.")

    return "\n".join(sections)


# --- Parallel E2E Script Generation ---


def _e2e_staging_dir(project_dir: str) -> str:
    """Return the staging directory for pre-generated E2E scripts."""
    return os.path.join(project_dir, ".boris", "e2e_scripts")


def _e2e_proof_dir(project_dir: str) -> str:
    """Return the directory where E2E proof images are expected."""
    return os.path.join(project_dir, ".boris", "e2e_proofs")


def generate_e2e_scripts_parallel(plan: Plan, task: str, project_dir: str):
    """Analyze the structural plan and pre-generate Playwright E2E scripts in a staging dir.

    This runs in a background thread during structural milestone execution so that
    scripts are ready by the time the E2E phase begins.

    Returns a dict mapping scenario names to generated script file paths, or {} on failure.
    """
    staging_dir = _e2e_staging_dir(project_dir)
    proof_dir = _e2e_proof_dir(project_dir)
    os.makedirs(staging_dir, exist_ok=True)
    os.makedirs(proof_dir, exist_ok=True)

    milestones_text = "\n".join(
        f"- {m.id}: {m.title} — {m.description[:200]}"
        for m in plan.milestones
    )

    prompt = (
        "You are an E2E test script generator. Given a project plan, generate Playwright test scripts "
        "that cover every user interaction implied by the milestones.\n\n"
        f"Project task: {task}\n"
        f"Project directory: {project_dir}\n\n"
        f"Milestones:\n{milestones_text}\n\n"
        "For EACH user-facing flow implied by the milestones, generate a complete Playwright test script.\n\n"
        "Return ONLY a JSON array of objects, each with:\n"
        "- scenario: Short name of the user interaction (e.g. 'user-signup', 'audio-import')\n"
        "- filename: The .spec.ts filename (e.g. 'user-signup.spec.ts')\n"
        "- script_content: The full Playwright TypeScript test script content\n"
        "- proof_image: The expected screenshot filename (e.g. 'user-signup-proof.png')\n\n"
        "Each script MUST include a page.screenshot() call that saves to the proof_image path.\n"
        "Return ONLY the JSON array."
    )

    try:
        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"
        result = subprocess.run(
            [CLAUDE_CMD, "-p", "--no-session-persistence"],
            input=prompt,
            capture_output=True,
            timeout=600,
            env=env,
            encoding="utf-8",
            errors="replace",
        )
        if result.returncode != 0:
            logger.warning("E2E script generation failed (exit %d)", result.returncode)
            return {}

        response = result.stdout.strip()
        json_str = _extract_json_array(response)
        scripts_data = json.loads(json_str)

        generated = {}
        for item in scripts_data:
            filename = item.get("filename", "")
            content = item.get("script_content", "")
            scenario = item.get("scenario", filename.replace(".spec.ts", ""))
            proof_image = item.get("proof_image", f"{scenario}-proof.png")

            if filename and content:
                script_path = os.path.join(staging_dir, filename)
                with open(script_path, "w", encoding="utf-8") as f:
                    f.write(content)
                generated[scenario] = {
                    "script_path": script_path,
                    "proof_image_path": os.path.join(proof_dir, proof_image),
                    "scenario": scenario,
                }
                logger.info("Pre-generated E2E script: %s -> %s", scenario, script_path)

        logger.info("Parallel E2E script generation complete: %d scripts", len(generated))
        return generated

    except (json.JSONDecodeError, subprocess.TimeoutExpired, FileNotFoundError) as e:
        logger.warning("E2E script generation failed: %s", e)
        return {}


class E2EScriptGenerator:
    """Manages background E2E script generation that runs parallel to structural milestones."""

    def __init__(self):
        self._thread = None
        self._result = {}
        self._done = False

    def start(self, plan: Plan, task: str, project_dir: str):
        """Start generating E2E scripts in a background thread."""
        import threading

        def _worker():
            try:
                self._result = generate_e2e_scripts_parallel(plan, task, project_dir)
            except Exception as e:
                logger.warning("E2E script generator thread failed: %s", e)
                self._result = {}
            finally:
                self._done = True

        self._thread = threading.Thread(target=_worker, daemon=True)
        self._thread.start()
        logger.info("E2E script generator started in background thread")
        print("  [Boris] E2E script generator started in background (parallel to structural milestones)", flush=True)

    def get_scripts(self, timeout: float = 300.0) -> dict:
        """Wait for scripts to be ready and return the result dict. Safe to call multiple times."""
        if self._thread is not None and self._thread.is_alive():
            print("  [Boris] Waiting for E2E script generation to complete...", flush=True)
            self._thread.join(timeout=timeout)
        return self._result

    @property
    def is_done(self) -> bool:
        return self._done


# Module-level singleton so boris.py can access it
_e2e_script_generator = E2EScriptGenerator()


def get_e2e_script_generator() -> E2EScriptGenerator:
    """Return the module-level E2E script generator instance."""
    return _e2e_script_generator


# --- E2E Testing Phase (mandatory after structural milestones) ---


def create_e2e_plan(task: str, project_dir: str, completed_milestones: list[Milestone] = None, standalone: bool = False) -> E2EPlan:
    """Create E2E testing milestones using Claude CLI.

    After all structural milestones are complete, Boris enters the E2E phase.
    Claude analyzes the built project and generates end-to-end test milestones
    that verify the entire application works as a whole — not just individual units.

    This covers: Playwright for web UI, audio verification for DAWs, API integration
    tests, database round-trips, or any outside-the-codebase verification needed.
    """
    file_listing = _list_project_files(project_dir)

    # Check for project-provided E2E context files
    e2e_context_content = ""
    e2e_context_files = ["e2e_context.md", "e2e_task.md", "e2e_rules.md"]
    for filename in e2e_context_files:
        filepath = os.path.join(project_dir, filename)
        if os.path.isfile(filepath):
            try:
                with open(filepath, "r", encoding="utf-8") as f:
                    content = f.read().strip()
                e2e_context_content += f"\n\n--- {filename} ---\n{content}\n"
            except Exception:
                pass

    completed_text = ""
    if completed_milestones:
        completed_text = "\n".join(
            f"- {m.id}: {m.title} — {', '.join(m.acceptance_criteria[:2])}"
            for m in completed_milestones
        )

    if standalone:
        # Standalone E2E: generate a SINGLE focused milestone covering the user's entire flow
        prompt = (
            "You are an E2E (end-to-end) testing expert. The user has requested a SPECIFIC E2E test session.\n"
            "You must create a SINGLE, comprehensive E2E milestone that tests EXACTLY what the user described — "
            "nothing more, nothing less. Do NOT generate generic app-wide coverage.\n\n"
            "CRITICAL RULE: Generate EXACTLY ONE milestone (E2E1) that covers the ENTIRE user-requested flow.\n"
            "Do NOT split the flow into multiple milestones. A single DaveLoop session (a persistent 'David') "
            "will execute this milestone and handle the entire E2E flow end-to-end in one pass.\n"
            "Multiple milestones cause unnecessary context loss between DaveLoop sessions — keep it as ONE.\n\n"
            f"User's E2E request: {task}\n\n"
        )
        if e2e_context_content:
            prompt += (
                "IMPORTANT: The project has provided E2E context files with architecture docs, "
                "exact test commands, and rules. USE THESE as the source of truth for your plan. "
                "Do NOT invent your own test commands — extract them from the context files below.\n"
                f"{e2e_context_content}\n\n"
            )
        else:
            prompt += f"Current project files:\n{file_listing}\n\n"
        if completed_text:
            prompt += f"Completed structural milestones (for context only):\n{completed_text}\n\n"

        prompt += (
            "First, determine:\n"
            "- project_type: \"web\", \"api\", \"cli\", \"android\", \"ios\", \"desktop\", \"daw\", or \"cross-platform\"\n"
            "- test_tool: Choose the BEST tool for this specific test flow:\n"
            "  - Web UI: \"playwright\" (browser automation)\n"
            "  - API: \"httpie\" or \"curl\" (HTTP endpoint testing)\n"
            "  - CLI: \"bash\" (command-line invocation testing)\n"
            "  - Android: \"maestro\" (mobile UI automation)\n"
            "  - Desktop/DAW: \"bash\" (process launch + output verification)\n"
            "  - Audio/Media: \"ffprobe\" or \"bash\" (audio stream verification)\n\n"
            "Create a SINGLE detailed milestone that encompasses the user's ENTIRE requested flow.\n"
            "The milestone must:\n"
            "- Cover ALL steps of the user's requested flow in ONE milestone\n"
            "- Have a detailed description (5-10 sentences) explaining the full flow: what to do step-by-step, "
            "what to check at each stage, and what counts as passing overall\n"
            "- Include ALL concrete test_commands needed for the entire flow (can be multiple commands)\n"
            "- Include ALL acceptance_criteria for the entire flow (not vague — exact observable outcomes)\n\n"
            "DO NOT:\n"
            "- Split the flow into multiple milestones — use EXACTLY ONE\n"
            "- Generate generic milestones like 'test homepage loads' unless the user asked for it\n"
            "- Add milestones for flows the user didn't mention\n"
            "- Write one-liner descriptions — be detailed and specific\n\n"
            "IMPORTANT: The single milestone MUST include ALL concrete test_commands that can be executed from the shell.\n"
            "These commands must produce clear PASS/FAIL output.\n\n"
            "Return ONLY a JSON object (no other text):\n"
            "{\n"
            '  "project_type": "web",\n'
            '  "test_tool": "playwright",\n'
            '  "milestones": [\n'
            "    {\n"
            '      "id": "E2E1",\n'
            '      "title": "Complete E2E test: <user-requested flow summary>",\n'
            '      "description": "Detailed 5-10 sentence description covering the ENTIRE flow end-to-end. '
            "Describe each step the test agent should perform, what assertions to make at each stage, "
            'and what the overall passing criteria look like. Be specific to the user\'s request.",\n'
            '      "test_commands": ["cmd1", "cmd2", "cmd3"],\n'
            '      "acceptance_criteria": ["All steps of the flow pass", "Specific outcome 1", "Specific outcome 2"]\n'
            "    }\n"
            "  ]\n"
            "}"
        )
    else:
        # Structural E2E: generic plan covering the app's core flows
        prompt = (
            "You are an E2E (end-to-end) testing expert. A project has just been fully built and you need "
            "to create comprehensive E2E test milestones that verify the ENTIRE application works correctly "
            "from an OUTSIDE perspective — not unit tests, not code reviews, but real end-to-end verification.\n\n"
            f"Original task: {task}\n"
            f"Current project files:\n{file_listing}\n\n"
        )
        if completed_text:
            prompt += f"Completed structural milestones:\n{completed_text}\n\n"

        prompt += (
            "First, determine:\n"
            "- project_type: \"web\", \"api\", \"cli\", \"android\", \"ios\", \"desktop\", \"daw\", or \"cross-platform\"\n"
            "- test_tool: Choose the BEST tool for this project type:\n"
            "  - Web UI: \"playwright\" (browser automation)\n"
            "  - API: \"httpie\" or \"curl\" (HTTP endpoint testing)\n"
            "  - CLI: \"bash\" (command-line invocation testing)\n"
            "  - Android: \"maestro\" (mobile UI automation)\n"
            "  - Desktop/DAW: \"bash\" (process launch + output verification)\n"
            "  - Audio/Media: \"ffprobe\" or \"bash\" (audio stream verification)\n\n"
            "Then create ordered E2E testing milestones. Each milestone tests ONE complete user flow or "
            "integration path END-TO-END. These are NOT unit tests — they verify the app works as a whole.\n\n"
            "Focus on:\n"
            "1. Critical user journeys (signup → use feature → see result)\n"
            "2. Integration verification (frontend calls backend correctly, DB persists data)\n"
            "3. External system checks (audio output works, file exports are valid, APIs respond correctly)\n"
            "4. Error resilience (invalid input handling, network failure recovery)\n"
            "5. Performance smoke tests (pages load within acceptable time, no memory leaks)\n\n"
            "IMPORTANT: Each milestone MUST include concrete test_commands that can be executed from the shell.\n"
            "These commands must produce clear PASS/FAIL output. Examples:\n"
            "- Web: `npx playwright test e2e/login.spec.ts`\n"
            "- API: `curl -s http://localhost:3000/api/health | grep '\"status\":\"ok\"'`\n"
            "- CLI: `./myapp --version && echo PASS`\n"
            "- Audio: `ffprobe -i output.wav 2>&1 | grep 'Audio:' && echo PASS`\n\n"
            "Return ONLY a JSON object (no other text):\n"
            "{\n"
            '  "project_type": "web",\n'
            '  "test_tool": "playwright",\n'
            '  "milestones": [\n'
            "    {\n"
            '      "id": "E2E1",\n'
            '      "title": "Test complete signup flow",\n'
            '      "description": "Test that a new user can sign up, verify email, and see the dashboard...",\n'
            '      "test_commands": ["npx playwright test e2e/signup.spec.ts"],\n'
            '      "acceptance_criteria": ["User can complete signup", "Dashboard loads after signup"]\n'
            "    }\n"
            "  ]\n"
            "}"
        )

    try:
        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"

        import threading
        import time

        print("  [Boris] E2E Planning phase - Claude is analyzing the built project...", flush=True)
        print(flush=True)

        plan_result = {"stdout": "", "returncode": None, "error": None}

        def _run_claude():
            try:
                cmd = [CLAUDE_CMD, "-p", "--no-session-persistence"]
                result = subprocess.run(
                    cmd,
                    input=prompt,
                    capture_output=True,
                    timeout=600,
                    env=env,
                    encoding="utf-8",
                    errors="replace",
                )
                plan_result["stdout"] = result.stdout
                plan_result["returncode"] = result.returncode
                if result.returncode != 0:
                    err = result.stderr.strip()
                    if not err:
                        err = result.stdout.strip()[:500]
                    plan_result["error"] = err
            except subprocess.TimeoutExpired:
                plan_result["error"] = "timeout"
            except FileNotFoundError:
                plan_result["error"] = "not_found"

        thread = threading.Thread(target=_run_claude, daemon=True)
        thread.start()

        elapsed = 0
        spinner = ["|", "/", "-", "\\"]
        while thread.is_alive():
            idx = (elapsed // 2) % len(spinner)
            sys.stdout.write(f"\r  [Boris] {spinner[idx]} Claude is creating E2E plan... ({elapsed}s)")
            sys.stdout.flush()
            time.sleep(2)
            elapsed += 2

        sys.stdout.write("\r" + " " * 60 + "\r")
        sys.stdout.flush()

        if plan_result["error"] == "timeout":
            raise subprocess.TimeoutExpired(CLAUDE_CMD, 600)
        if plan_result["error"] == "not_found":
            raise FileNotFoundError(CLAUDE_CMD)
        if plan_result["returncode"] != 0:
            raise RuntimeError(f"Claude CLI failed (exit {plan_result['returncode']}): {plan_result['error']}")

        response = plan_result["stdout"].strip()
        logger.debug("Claude E2E plan raw response: %s", response[:500])

        print("  [Boris] E2E Plan received! Parsing...", flush=True)

        # Extract JSON from response
        json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
        if json_match:
            response = json_match.group(1).strip()
        else:
            obj_match = re.search(r"\{.*\}", response, re.DOTALL)
            if obj_match:
                response = obj_match.group(0).strip()

        data = json.loads(response)

        project_type = data.get("project_type", "unknown")
        test_tool = data.get("test_tool", "bash")
        milestones_data = data.get("milestones", data if isinstance(data, list) else [])

        print(f"  [Boris] Project type: {project_type}, E2E tool: {test_tool}", flush=True)

        # Collect pre-generated scripts from the parallel background generator
        pre_generated = get_e2e_script_generator().get_scripts()
        staging_dir = _e2e_staging_dir(project_dir)
        proof_dir = _e2e_proof_dir(project_dir)

        e2e_milestones = []
        for m in milestones_data:
            mid = m["id"]
            title = m["title"]
            # Try to match a pre-generated script by scenario keyword overlap
            script_path = None
            proof_image_path = None
            scenario = None
            title_lower = title.lower().replace(" ", "-")
            for scn, info in pre_generated.items():
                if scn in title_lower or title_lower in scn:
                    script_path = info["script_path"]
                    proof_image_path = info["proof_image_path"]
                    scenario = info["scenario"]
                    break
            # Fallback: assign default paths if no match found
            if script_path is None:
                safe_id = mid.lower()
                script_path = os.path.join(staging_dir, f"{safe_id}.spec.ts")
                proof_image_path = os.path.join(proof_dir, f"{safe_id}-proof.png")
                scenario = m.get("description", title)[:200]

            em = E2EMilestone(
                id=mid,
                title=title,
                description=m["description"],
                test_tool=m.get("test_tool", test_tool),
                test_commands=m.get("test_commands", []),
                acceptance_criteria=m.get("acceptance_criteria", []),
                playwright_script_path=script_path,
                proof_image_path=proof_image_path,
                scenario=scenario,
            )
            e2e_milestones.append(em)

        for em in e2e_milestones:
            script_exists = os.path.isfile(em.playwright_script_path) if em.playwright_script_path else False
            marker = "ready" if script_exists else "pending"
            print(f"  [Boris]   {em.id}: {em.title} [script: {marker}]", flush=True)
        print(flush=True)

        return E2EPlan(
            project_type=project_type,
            test_tool=test_tool,
            milestones=e2e_milestones,
        )

    except json.JSONDecodeError as e:
        raise RuntimeError(f"Failed to parse Claude's E2E plan response as JSON: {e}") from e
    except subprocess.TimeoutExpired:
        raise RuntimeError("Claude CLI timed out while generating E2E plan")
    except FileNotFoundError:
        raise RuntimeError(f"'{CLAUDE_CMD}' command not found. Is Claude CLI installed?")


def craft_e2e_prompt(e2e_milestone: E2EMilestone, e2e_plan: E2EPlan, plan: Plan, project_dir: str) -> str:
    """Build a prompt for DaveLoop in E2E Tester Mode.

    DaveLoop will write and run the actual E2E tests, ensuring the built project
    works end-to-end from an outside perspective.
    """
    tool_name = e2e_milestone.test_tool.capitalize()
    sections = []

    # MODE DECLARATION
    sections.append(f"# MODE: E2E TESTER (Boris E2E Phase)")
    sections.append(f"# Tool: {tool_name}")
    sections.append("")
    sections.append("You are in E2E TESTER MODE. You do NOT build new features.")
    sections.append("You WRITE and RUN end-to-end tests that verify the built application works correctly.")
    sections.append("If an E2E test reveals a real bug, you FIX the bug and re-run the test.")
    sections.append("")

    # Project-provided E2E context files (override generated commands/context)
    e2e_context_files = [
        ("e2e_context.md", "E2E Context (Architecture, Endpoints, Store API)"),
        ("e2e_task.md", "E2E Task (Step-by-Step Test Commands)"),
        ("e2e_rules.md", "E2E Rules (Anti-Patterns and Time Budgets)"),
    ]
    has_context_files = False
    for filename, label in e2e_context_files:
        filepath = os.path.join(project_dir, filename)
        if os.path.isfile(filepath):
            has_context_files = True
            try:
                with open(filepath, "r", encoding="utf-8") as f:
                    content = f.read().strip()
                sections.append(f"## {label}")
                sections.append(f"*Source: {filename}*")
                sections.append("")
                sections.append(content)
                sections.append("")
            except Exception:
                pass

    if has_context_files:
        sections.append("## IMPORTANT: Use the context files above")
        sections.append("The project has provided e2e context, task commands, and rules.")
        sections.append("DO NOT invent your own test commands — use the ones from e2e_task.md.")
        sections.append("DO NOT read source files that are already documented in e2e_context.md.")
        sections.append("DO NOT violate the rules in e2e_rules.md.")
        sections.append("")

    # Task scope
    sections.append(f"## Your Task: {e2e_milestone.id} - {e2e_milestone.title}")
    sections.append("")
    sections.append(e2e_milestone.description)
    sections.append("")

    # Scenario context
    if e2e_milestone.scenario:
        sections.append(f"## Scenario")
        sections.append(e2e_milestone.scenario)
        sections.append("")

    # Pre-generated Playwright script (Ground Truth Protocol)
    if e2e_milestone.playwright_script_path:
        script_exists = os.path.isfile(e2e_milestone.playwright_script_path)
        if script_exists:
            sections.append("## Pre-Generated Playwright Script (USE THIS)")
            sections.append(f"A Playwright test script has been pre-generated for this milestone at:")
            sections.append(f"  `{e2e_milestone.playwright_script_path}`")
            sections.append("")
            sections.append("**Use this script as your starting point.** Read it, adapt if needed, and run it.")
            sections.append("Do NOT write a new script from scratch unless this one is fundamentally broken.")
            sections.append("")
        else:
            sections.append("## Playwright Script Path")
            sections.append(f"Write your Playwright test script to: `{e2e_milestone.playwright_script_path}`")
            sections.append("")

    # Ground Truth Protocol: proof image requirement
    if e2e_milestone.proof_image_path:
        sections.append("## GROUND TRUTH PROTOCOL — Screenshot Proof Required")
        sections.append("**CRITICAL: This E2E milestone CANNOT be resolved without screenshot proof.**")
        sections.append(f"You MUST capture a screenshot and save it to: `{e2e_milestone.proof_image_path}`")
        sections.append("")
        sections.append("Methods to capture proof (try in order):")
        sections.append("1. Playwright `page.screenshot({ path: '<proof_path>' })` (preferred)")
        sections.append("2. MCP screenshot tool if available")
        sections.append("3. PowerShell: `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ...`")
        sections.append("")
        sections.append(f"**The file `{e2e_milestone.proof_image_path}` MUST exist on disk before you emit [DAVELOOP:RESOLVED].**")
        sections.append("Boris will verify this file exists. If it doesn't, resolution will be REJECTED.")
        sections.append("")

    # Test commands
    if e2e_milestone.test_commands:
        sections.append("## Test Commands to Run")
        sections.append("These are the E2E test commands you must execute. If the test file doesn't exist yet, CREATE it first.")
        for cmd in e2e_milestone.test_commands:
            sections.append(f"- `{cmd}`")
        sections.append("")

    # Acceptance criteria
    sections.append("## Acceptance Criteria")
    for criterion in e2e_milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    if e2e_milestone.proof_image_path:
        sections.append(f"- Screenshot proof saved to `{e2e_milestone.proof_image_path}`")
    sections.append("")

    # Instructions
    sections.append("## E2E Testing Instructions")
    sections.append("1. Set up any prerequisites (start servers, seed databases, etc.)")
    if e2e_milestone.playwright_script_path and os.path.isfile(e2e_milestone.playwright_script_path):
        sections.append(f"2. Read and run the pre-generated script at `{e2e_milestone.playwright_script_path}`")
    else:
        sections.append("2. Write E2E test files if they don't exist")
    sections.append("3. Run the E2E tests")
    sections.append("4. If tests FAIL because of a real bug in the application, FIX the bug")
    sections.append("5. Re-run the tests until they PASS")
    sections.append("6. Capture screenshot proof (see GROUND TRUTH PROTOCOL above)")
    sections.append("7. Report: `E2E PASS: <description>` for passing tests")
    sections.append("8. Report: `E2E BUG FIXED: <description>` for bugs you fixed")
    sections.append("")

    # Brief context
    sections.append("---")
    sections.append(f"Project: {plan.task}")
    sections.append(f"Directory: {project_dir}")
    sections.append(f"Type: {e2e_plan.project_type}")
    sections.append("")

    # File listing — skip if project provided context files (prevents DaveLoop from exploring)
    if not has_context_files:
        sections.append("### Current Files")
        sections.append(_list_project_files(project_dir))
        sections.append("")

    sections.append(f"**Do NOT build new features. Test {e2e_milestone.id} end-to-end only. Fix real bugs if found.**")

    return "\n".join(sections)


def craft_e2e_correction(output: str, e2e_milestone: E2EMilestone, e2e_plan: E2EPlan, reason: str) -> str:
    """Build a correction prompt for DaveLoop in E2E Tester Mode after a failed attempt."""
    tool_name = e2e_milestone.test_tool.capitalize()
    sections = []

    sections.append(f"# MODE: E2E TESTER (Boris E2E Phase) - CORRECTION")
    sections.append(f"# Tool: {tool_name}")
    sections.append("")
    sections.append(f"## Correction Required: {e2e_milestone.title}")
    sections.append(f"**Problem:** {reason}")
    sections.append("")

    # Output tail
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    tail = output_lines[-200:] if len(output_lines) > 200 else output_lines
    sections.append("## Previous Output (last 200 lines)")
    sections.append("```")
    sections.append("\n".join(tail))
    sections.append("```")
    sections.append("")

    sections.append(f"Fix the issues and complete {e2e_milestone.id}: {e2e_milestone.title}")
    sections.append(e2e_milestone.description)
    sections.append("")

    sections.append("## Acceptance Criteria")
    for criterion in e2e_milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    sections.append("Report: `E2E PASS: <description>` for passing tests")
    sections.append("Report: `E2E BUG FIXED: <description>` for bugs fixed")
    sections.append("")
    sections.append("**Do NOT build new features. E2E testing and bug fixing only.**")

    return "\n".join(sections)


# --- UI Testing & Polish (DaveLoop v1.4) ---


def create_ui_plan(task: str, project_dir: str) -> UIPlan:
    """Create UI testing milestones using Claude CLI.

    Claude already knows the project from the task + file listing.
    It decides the project type and test tool itself.
    """
    file_listing = _list_project_files(project_dir)

    prompt = (
        "You are a UI/QA testing expert. This project was just built and needs UI testing and polish.\n\n"
        f"Original task: {task}\n"
        f"Current files:\n{file_listing}\n\n"
        "First, determine:\n"
        "- project_type: \"web\", \"android\", \"ios\", or \"cross-platform\"\n"
        "- test_tool: \"playwright\" (for web) or \"maestro\" (for Android/iOS)\n\n"
        "Then create ordered UI testing milestones. Each milestone tests ONE user flow or UI aspect.\n"
        "Focus on:\n"
        "1. Core user flows (login, main feature, navigation)\n"
        "2. Visual polish (alignment, spacing, responsive/adaptive)\n"
        "3. Edge cases (empty states, error states, loading states)\n"
        "4. Accessibility (contrast, labels, screen reader)\n\n"
        "Return ONLY a JSON object (no other text):\n"
        "{\n"
        '  "project_type": "web",\n'
        '  "test_tool": "playwright",\n'
        '  "milestones": [\n'
        "    {\n"
        '      "id": "UI1",\n'
        '      "title": "Test Login Flow",\n'
        '      "description": "Test the login flow...",\n'
        '      "test_commands": ["npx playwright test login.spec.ts"],\n'
        '      "acceptance_criteria": ["Login form displays correctly", "Can enter credentials and submit"]\n'
        "    }\n"
        "  ]\n"
        "}"
    )

    try:
        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"

        import threading
        import time

        print("  [Boris] UI Planning phase - Claude is thinking...", flush=True)
        print(flush=True)

        plan_result = {"stdout": "", "returncode": None, "error": None}

        def _run_claude():
            try:
                cmd = [CLAUDE_CMD, "-p", "--no-session-persistence"]
                result = subprocess.run(
                    cmd,
                    input=prompt,
                    capture_output=True,
                    timeout=600,
                    env=env,
                    encoding="utf-8",
                    errors="replace",
                )
                plan_result["stdout"] = result.stdout
                plan_result["returncode"] = result.returncode
                if result.returncode != 0:
                    err = result.stderr.strip()
                    if not err:
                        err = result.stdout.strip()[:500]
                    plan_result["error"] = err
            except subprocess.TimeoutExpired:
                plan_result["error"] = "timeout"
            except FileNotFoundError:
                plan_result["error"] = "not_found"

        thread = threading.Thread(target=_run_claude, daemon=True)
        thread.start()

        elapsed = 0
        spinner = ["|", "/", "-", "\\"]
        while thread.is_alive():
            idx = (elapsed // 2) % len(spinner)
            sys.stdout.write(f"\r  [Boris] {spinner[idx]} Claude is creating UI plan... ({elapsed}s)")
            sys.stdout.flush()
            time.sleep(2)
            elapsed += 2

        sys.stdout.write("\r" + " " * 60 + "\r")
        sys.stdout.flush()

        if plan_result["error"] == "timeout":
            raise subprocess.TimeoutExpired(CLAUDE_CMD, 600)
        if plan_result["error"] == "not_found":
            raise FileNotFoundError(CLAUDE_CMD)
        if plan_result["returncode"] != 0:
            raise RuntimeError(f"Claude CLI failed (exit {plan_result['returncode']}): {plan_result['error']}")

        response = plan_result["stdout"].strip()
        logger.debug("Claude UI plan raw response: %s", response[:500])

        print("  [Boris] UI Plan received! Parsing...", flush=True)

        # Extract JSON from response
        json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
        if json_match:
            response = json_match.group(1).strip()
        else:
            # Try to find raw JSON object or array
            obj_match = re.search(r"\{.*\}", response, re.DOTALL)
            if obj_match:
                response = obj_match.group(0).strip()

        data = json.loads(response)

        # Claude tells us what the project is and what tool to use
        project_type = data.get("project_type", "web")
        test_tool = data.get("test_tool", "playwright")
        milestones_data = data.get("milestones", data if isinstance(data, list) else [])

        print(f"  [Boris] Project type: {project_type}, tool: {test_tool}", flush=True)

        ui_milestones = [
            UIMilestone(
                id=m["id"],
                title=m["title"],
                description=m["description"],
                test_tool=m.get("test_tool", test_tool),
                test_commands=m.get("test_commands", []),
                acceptance_criteria=m.get("acceptance_criteria", []),
            )
            for m in milestones_data
        ]

        for um in ui_milestones:
            print(f"  [Boris]   {um.id}: {um.title}", flush=True)
        print(flush=True)

        return UIPlan(
            project_type=project_type,
            test_tool=test_tool,
            milestones=ui_milestones,
        )

    except json.JSONDecodeError as e:
        raise RuntimeError(f"Failed to parse Claude's UI plan response as JSON: {e}") from e
    except subprocess.TimeoutExpired:
        raise RuntimeError("Claude CLI timed out while generating UI plan")
    except FileNotFoundError:
        raise RuntimeError(f"'{CLAUDE_CMD}' command not found. Is Claude CLI installed?")


def craft_ui_prompt(ui_milestone: UIMilestone, ui_plan: UIPlan, plan: Plan, project_dir: str) -> str:
    """Build a prompt for DaveLoop in UI Tester Mode.

    Boris keeps it simple: scope the task, tell DaveLoop it's in UI test mode,
    and let DaveLoop figure out the tooling. DaveLoop knows Playwright/Maestro.
    """
    tool_name = "Playwright" if ui_plan.test_tool == "playwright" else "Maestro"
    sections = []

    # MODE DECLARATION - short and clear
    sections.append(f"# MODE: UI TESTER (DaveLoop v1.4)")
    sections.append(f"# Tool: {tool_name}")
    sections.append("")
    sections.append("You are in UI TESTER MODE. You do NOT build new features.")
    sections.append("You TEST existing UI, FIND issues, and FIX visual/UX problems.")
    sections.append("")

    # Task scope
    sections.append(f"## Your Task: {ui_milestone.id} - {ui_milestone.title}")
    sections.append("")
    sections.append(ui_milestone.description)
    sections.append("")

    # Test commands (if the plan specified any)
    if ui_milestone.test_commands:
        sections.append("## Test Commands")
        for cmd in ui_milestone.test_commands:
            sections.append(f"- `{cmd}`")
        sections.append("")

    # Acceptance criteria
    sections.append("## Acceptance Criteria")
    for criterion in ui_milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    # Reporting markers so Boris can parse issues/fixes from output
    sections.append("## Reporting")
    sections.append("Report issues as: `ISSUE FOUND: <description>`")
    sections.append("Report fixes as: `FIX APPLIED: <description>`")
    sections.append("")

    # Brief context
    sections.append("---")
    sections.append(f"Project: {plan.task}")
    sections.append(f"Directory: {project_dir}")
    sections.append(f"Type: {ui_plan.project_type}")
    sections.append("")

    # File listing so DaveLoop knows what exists
    sections.append("### Current Files")
    sections.append(_list_project_files(project_dir))
    sections.append("")

    sections.append(f"**Do NOT build new features. Test {ui_milestone.id} only.**")

    return "\n".join(sections)


def craft_ui_correction(output: str, ui_milestone: UIMilestone, ui_plan: UIPlan, reason: str) -> str:
    """Build a correction prompt for DaveLoop in UI Tester Mode after an off-plan or failed attempt."""
    tool_name = "Playwright" if ui_plan.test_tool == "playwright" else "Maestro"
    sections = []

    sections.append(f"# MODE: UI TESTER (DaveLoop v1.4) - CORRECTION")
    sections.append(f"# Tool: {tool_name}")
    sections.append("")
    sections.append(f"## Correction Required: {ui_milestone.title}")
    sections.append(f"**Problem:** {reason}")
    sections.append("")

    # Output tail
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    tail = output_lines[-200:] if len(output_lines) > 200 else output_lines
    sections.append("## Previous Output (last 200 lines)")
    sections.append("```")
    sections.append("\n".join(tail))
    sections.append("```")
    sections.append("")

    sections.append(f"Fix the issues and complete {ui_milestone.id}: {ui_milestone.title}")
    sections.append(ui_milestone.description)
    sections.append("")

    sections.append("## Acceptance Criteria")
    for criterion in ui_milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    sections.append("Report issues: `ISSUE FOUND: <description>`")
    sections.append("Report fixes: `FIX APPLIED: <description>`")
    sections.append("")
    sections.append("**Do NOT build new features. UI testing only.**")

    return "\n".join(sections)
