"""GraQle MCP Development Server — governed development for Claude Code.

Production MCP server exposing 7 tools over JSON-RPC stdio transport.
Replaces flat-file CLAUDE.md reading with graph-powered context engineering.

FREE tier (3 tools):
    1. graq_context  — Smart context loading for session start
    2. graq_inspect   — Graph structure inspection
    3. graq_reason    — Graph-of-agents reasoning

PRO tier (4 tools, license-gated):
    4. graq_preflight — Governance check before code changes
    5. graq_lessons   — Query lessons relevant to current work
    6. graq_impact    — Trace downstream impacts of a change
    7. graq_learn     — Feed outcomes back for Bayesian graph learning

Usage:
    graq mcp serve                     # stdio transport (Claude Code)
    graq mcp serve --config my.yaml    # custom config

Claude Code .mcp.json:
    {
      "mcpServers": {
        "graq": {
          "command": "graq",
          "args": ["mcp", "serve"]
        }
      }
    }
"""

# ── graqle:intelligence ──
# module: graqle.plugins.mcp_dev_server
# risk: HIGH (impact radius: 5 modules)
# consumers: __init__, test_impact_filtering, test_lesson_hit_count, test_mcp_dev_server, test_mcp_dev_server_v015
# dependencies: __future__, json, logging, sys, asyncio +4 more
# constraints: none
# ── /graqle:intelligence ──

from __future__ import annotations

import asyncio
import json
import logging
import sys
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

logger = logging.getLogger("graqle.mcp")

try:
    from graqle.__version__ import __version__ as _version
except Exception:
    _version = "0.0.0"

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


_SENSITIVE_KEYS = frozenset({"api_key", "secret", "password", "token", "credential"})

_LESSON_ENTITY_TYPES = frozenset({
    "LESSON", "MISTAKE", "SAFETY", "ADR", "DECISION",
    "REQUIREMENT", "PROCEDURE", "DEFINITION",
})

_MAX_BFS_DEPTH = 3
_MAX_RESULTS = 50

# ---------------------------------------------------------------------------
# Tool Definitions (MCP protocol)
# ---------------------------------------------------------------------------

TOOL_DEFINITIONS: list[dict[str, Any]] = [
    # ── FREE tier ──────────────────────────────────────────────────────────
    {
        "name": "graq_context",
        "description": (
            "Get smart, focused context for your current task. "
            "Returns relevant knowledge graph nodes, active branch info, "
            "and applicable lessons in ~300-500 tokens. "
            "Use at session start instead of reading large files."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "task": {
                    "type": "string",
                    "description": "What you're working on",
                },
                "level": {
                    "type": "string",
                    "enum": ["minimal", "standard", "deep"],
                    "default": "standard",
                    "description": (
                        "Context depth: minimal (~200 tokens), "
                        "standard (~400 tokens), deep (~800 tokens)"
                    ),
                },
                "caller": {
                    "type": "string",
                    "description": "Caller identifier for multi-agent tracking (e.g., 'agent-1', 'ci-pipeline')",
                },
            },
            "required": ["task"],
        },
    },
    {
        "name": "graq_inspect",
        "description": (
            "Inspect the project knowledge graph structure. "
            "Show nodes, edges, stats, or details for a specific node."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "node_id": {
                    "type": "string",
                    "description": "Specific node to inspect (optional)",
                },
                "stats": {
                    "type": "boolean",
                    "default": False,
                    "description": "Show full graph statistics",
                },
            },
        },
    },
    {
        "name": "graq_reason",
        "description": (
            "Run graph-of-agents reasoning over the project knowledge graph. "
            "Each relevant node becomes an agent that reasons about your question, "
            "exchanges messages with neighbors, and collectively produces "
            "a synthesized answer."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "question": {
                    "type": "string",
                    "description": "Your question about the project",
                },
                "max_rounds": {
                    "type": "integer",
                    "default": 2,
                    "description": "Maximum message-passing rounds (1-5)",
                },
                "caller": {
                    "type": "string",
                    "description": "Caller identifier for multi-agent tracking (e.g., 'agent-1', 'ci-pipeline')",
                },
            },
            "required": ["question"],
        },
    },
    {
        "name": "graq_reason_batch",
        "description": (
            "Run multiple reasoning queries in parallel over the knowledge graph. "
            "Each query gets its own graph-of-agents reasoning session, running "
            "concurrently with semaphore-controlled parallelism. "
            "Use for analyzing multiple questions at once (e.g., batch code review).\n\n"
            "Set mode='predictive' to run each query through the full PSE prediction "
            "pipeline. fold_back applies to the entire batch (all-or-nothing)."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "questions": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of questions to reason about in parallel",
                },
                "mode": {
                    "type": "string",
                    "enum": ["standard", "predictive"],
                    "default": "standard",
                    "description": (
                        "'standard' (default) uses standard reasoning. "
                        "'predictive' runs each query through the PSE pipeline independently."
                    ),
                },
                "fold_back": {
                    "type": "boolean",
                    "default": True,
                    "description": "In predictive mode: write predictions that pass the gate. Applies to entire batch.",
                },
                "confidence_threshold": {
                    "type": "number",
                    "default": 0.65,
                    "description": "In predictive mode: minimum answer_confidence to write a prediction (0.0-1.0).",
                },
                "max_rounds": {
                    "type": "integer",
                    "default": 2,
                    "description": "Maximum message-passing rounds per query (1-5)",
                },
                "max_concurrent": {
                    "type": "integer",
                    "default": 5,
                    "description": "Maximum concurrent reasoning sessions (1-10)",
                },
            },
            "required": ["questions"],
        },
    },
    # ── PRO tier ───────────────────────────────────────────────────────────
    {
        "name": "graq_preflight",
        "description": (
            "Run a governance preflight check before making code changes. "
            "Returns relevant lessons, past mistakes, applicable architectural "
            "decisions, and safety boundary warnings. "
            "Call this BEFORE modifying any file."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "description": "What you're about to do",
                },
                "files": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Files being changed (optional)",
                },
                "caller": {
                    "type": "string",
                    "description": "Caller identifier for multi-agent tracking (e.g., 'agent-1', 'ci-pipeline')",
                },
            },
            "required": ["action"],
        },
    },
    {
        "name": "graq_lessons",
        "description": (
            "Find lessons and past mistakes relevant to a specific operation. "
            "Returns matching lessons with severity levels and hit counts."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "operation": {
                    "type": "string",
                    "description": (
                        "The operation (e.g., 'deployment', "
                        "'database migration', 'auth changes')"
                    ),
                },
                "severity_filter": {
                    "type": "string",
                    "enum": ["all", "critical", "high"],
                    "default": "high",
                    "description": "Minimum severity to return",
                },
            },
            "required": ["operation"],
        },
    },
    {
        "name": "graq_impact",
        "description": (
            "Trace the downstream impact of a proposed change through "
            "the dependency graph. Shows which components are affected "
            "and their risk level."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "component": {
                    "type": "string",
                    "description": "Component being changed",
                },
                "change_type": {
                    "type": "string",
                    "enum": ["modify", "add", "remove", "deploy"],
                    "default": "modify",
                    "description": "Type of change being made",
                },
                "code_only": {
                    "type": "boolean",
                    "default": False,
                    "description": (
                        "Filter out non-code nodes (DOCUMENT, SECTION, CHUNK, "
                        "Directory, Config, EnvVar, etc.) from results. "
                        "Reduces noise significantly for large codebases."
                    ),
                },
            },
            "required": ["component"],
        },
    },
    {
        "name": "graq_safety_check",
        "description": (
            "Combined safety check: chains impact → preflight → reasoning "
            "into a single call. Gives a complete safety picture before "
            "making a change. Reasoning is only triggered if risk is "
            "medium or high (cost-aware). Equivalent to running "
            "graq_impact + graq_preflight + graq_reason in sequence."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "component": {
                    "type": "string",
                    "description": "Component or file to safety-check",
                },
                "change_type": {
                    "type": "string",
                    "enum": ["modify", "add", "remove", "deploy"],
                    "default": "modify",
                    "description": "Type of change being made",
                },
                "skip_reasoning": {
                    "type": "boolean",
                    "default": False,
                    "description": "Skip the reasoning step (faster, cheaper)",
                },
            },
            "required": ["component"],
        },
    },
    {
        "name": "graq_learn",
        "description": (
            "Teach the knowledge graph. Three modes:\n"
            "1. 'outcome' (default) — Record a dev outcome, adjust edge weights\n"
            "2. 'entity' — Add a business entity (PRODUCT, CLIENT, TEAM, etc.)\n"
            "3. 'knowledge' — Teach domain knowledge (brand rules, copy, etc.)\n"
            "Call after completing a task, or to enrich the graph with business context."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "mode": {
                    "type": "string",
                    "enum": ["outcome", "entity", "knowledge"],
                    "default": "outcome",
                    "description": "Learning mode: outcome (edge weight updates), entity (business nodes), knowledge (domain facts)",
                },
                "action": {
                    "type": "string",
                    "description": "[outcome mode] What was done",
                },
                "outcome": {
                    "type": "string",
                    "enum": ["success", "failure", "partial"],
                    "description": "[outcome mode] Task outcome",
                },
                "components": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "[outcome mode] Components involved",
                },
                "lesson": {
                    "type": "string",
                    "description": "[outcome mode] Optional new lesson learned",
                },
                "entity_id": {
                    "type": "string",
                    "description": "[entity mode] Unique entity ID (e.g. 'CrawlQ', 'Philips')",
                },
                "entity_type": {
                    "type": "string",
                    "description": "[entity mode] Type: PRODUCT, CLIENT, BUSINESS_OUTCOME, TEAM, SYNERGY, MARKET, COMPETITOR, METRIC",
                },
                "description": {
                    "type": "string",
                    "description": "[entity/knowledge mode] Description or fact text",
                },
                "connects_to": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "[entity mode] Node IDs to connect to",
                },
                "domain": {
                    "type": "string",
                    "description": "[knowledge mode] Domain: brand, copy, product, market, technical",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "[knowledge mode] Tags for retrieval",
                },
            },
            "required": [],
        },
    },
    {
        "name": "graq_predict",
        "description": (
            "Run governed reasoning over the knowledge graph and — if confidence "
            "is sufficient — write the compound prediction as a new subgraph into "
            "the graph permanently. Future similar queries activate the predicted "
            "subgraph directly without re-reasoning. "
            "Use for compound pattern detection, sub-threshold signal analysis, "
            "and latent failure chain discovery."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The reasoning query",
                },
                "max_rounds": {
                    "type": "integer",
                    "default": 3,
                    "description": "Maximum message-passing rounds (passed to graq_reason)",
                },
                "fold_back": {
                    "type": "boolean",
                    "default": True,
                    "description": "If false: reasons but does NOT write to graph (dry-run)",
                },
                "confidence_threshold": {
                    "type": "number",
                    "default": 0.45,
                    "description": "Minimum answer_confidence to trigger write-back (0.0-1.0)",
                },
                "similarity_threshold": {
                    "type": "number",
                    "default": 0.15,
                    "description": "Max cosine distance to existing nodes to detect duplicates",
                },
            },
            "required": ["query"],
        },
    },
    {
        "name": "graq_reload",
        "description": (
            "Force-reload the knowledge graph from disk. "
            "Use after external changes to graqle.json "
            "(e.g., after graq learn, graq scan, or manual edits)."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {},
        },
    },
    {
        "name": "graq_audit",
        "description": (
            "Deep health audit of knowledge graph chunk coverage. "
            "Goes beyond validate (which checks descriptions) to audit "
            "the actual evidence chunks that reasoning agents depend on. "
            "Catches hollow KGs where nodes have descriptions but no chunks. "
            "Returns health status: CRITICAL, WARNING, MODERATE, or HEALTHY."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "fix": {
                    "type": "boolean",
                    "default": False,
                    "description": "Auto-synthesize chunks for hollow nodes",
                },
                "verbose": {
                    "type": "boolean",
                    "default": False,
                    "description": "Include per-node chunk details in output",
                },
            },
        },
    },
    {
        "name": "graq_gate",
        "description": (
            "Pre-compiled intelligence gate — instant context for any module (<100ms). "
            "Returns risk level, impact radius, consumers, dependencies, constraints, "
            "incidents, and public interfaces from .graqle/intelligence/. "
            "No scanning needed. Use before modifying any file to understand blast radius. "
            "Run 'graq compile' first to populate intelligence."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "module": {
                    "type": "string",
                    "description": (
                        "Module name or file path to query. "
                        "Examples: 'graqle.core.graph', 'core/graph.py', 'graph'"
                    ),
                },
                "action": {
                    "type": "string",
                    "enum": ["context", "impact", "scorecard"],
                    "default": "context",
                    "description": (
                        "context: full module packet. "
                        "impact: what breaks if this module changes. "
                        "scorecard: overall project quality gate status."
                    ),
                },
            },
            "required": ["module"],
        },
    },
    {
        "name": "graq_gov_gate",
        "description": (
            "Governance gate — run the 3-tier GovernanceMiddleware check on a diff or file. "
            "Returns tier (TS-BLOCK/T1/T2/T3), blocked (bool), gate_score (0.0-1.0), "
            "and reason. Exit code 1 equivalent when blocked=true. "
            "Used by WorkflowOrchestrator GATE stage and CI enforcement. "
            "TS-BLOCK is unconditional — no bypass, no approval overrides it."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "File being changed (required for context).",
                },
                "diff": {
                    "type": "string",
                    "description": "Unified diff content to check for TS patterns and secrets.",
                    "default": "",
                },
                "content": {
                    "type": "string",
                    "description": "Full file content (alternative to diff).",
                    "default": "",
                },
                "risk_level": {
                    "type": "string",
                    "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
                    "default": "LOW",
                    "description": "Risk level from preflight check.",
                },
                "impact_radius": {
                    "type": "integer",
                    "default": 0,
                    "description": "Number of downstream consumers (from graq_preflight).",
                },
                "approved_by": {
                    "type": "string",
                    "default": "",
                    "description": "Approver identity (required for T3 changes).",
                },
                "justification": {
                    "type": "string",
                    "default": "",
                    "description": "Reason for change (recorded in audit trail).",
                },
                "actor": {
                    "type": "string",
                    "default": "",
                    "description": "Identity of the requesting actor.",
                },
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "graq_drace",
        "description": (
            "DRACE governance scoring — query AI reasoning audit trails "
            "and development governance quality scores. "
            "DRACE = Dependency + Reasoning + Auditability + Constraint + Explainability. "
            "Each reasoning session is scored on 5 pillars (0.0-1.0). "
            "Use to inspect AI decision transparency and evidence quality."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["sessions", "trail", "score"],
                    "default": "sessions",
                    "description": (
                        "sessions: list recent audit sessions. "
                        "trail: get full audit trail for a session. "
                        "score: get DRACE score breakdown for a session."
                    ),
                },
                "session_id": {
                    "type": "string",
                    "description": "Session ID (required for 'trail' and 'score' actions)",
                },
                "limit": {
                    "type": "integer",
                    "default": 10,
                    "description": "Max sessions to return (for 'sessions' action)",
                },
            },
        },
    },
    {
        "name": "graq_runtime",
        "description": (
            "Query live runtime observability data from your cloud environment. "
            "Auto-detects AWS CloudWatch, Azure Monitor, GCP Cloud Logging, "
            "or local Docker/file logs. Returns classified runtime events "
            "(errors, timeouts, throttles) with severity levels and hit counts. "
            "Use this for debugging production issues — it bridges the gap "
            "between static code knowledge and live system behavior."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": (
                        "What to investigate (e.g., 'errors in BAMR-API last 2 hours', "
                        "'Lambda timeouts', 'auth failures')"
                    ),
                },
                "source": {
                    "type": "string",
                    "enum": ["auto", "cloudwatch", "azure_monitor", "cloud_logging", "docker", "file"],
                    "default": "auto",
                    "description": "Log source (auto-detects if not specified)",
                },
                "service": {
                    "type": "string",
                    "description": "Filter to a specific service/Lambda/container name",
                },
                "hours": {
                    "type": "number",
                    "default": 6,
                    "description": "How far back to look (hours)",
                },
                "severity_filter": {
                    "type": "string",
                    "enum": ["all", "low", "medium", "high", "critical"],
                    "default": "high",
                    "description": "Minimum severity to return",
                },
                "ingest": {
                    "type": "boolean",
                    "default": False,
                    "description": "Also ingest runtime events as RUNTIME_EVENT nodes into the KG",
                },
            },
        },
    },
    {
        "name": "graq_route",
        "description": (
            "Smart query router — classifies your question and recommends "
            "whether to use GraQle tools or external tools (CloudWatch, grep, git). "
            "Call this BEFORE investigating to get the most efficient tool strategy. "
            "Returns: category, recommended tools, confidence, and reasoning."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "question": {
                    "type": "string",
                    "description": "The question or investigation topic to route",
                },
            },
            "required": ["question"],
        },
    },
    {
        "name": "graq_lifecycle",
        "description": (
            "Session lifecycle hooks for development workflows. "
            "Call at key moments: session_start (load context), "
            "investigation_start (before debugging), fix_complete (after a fix). "
            "Returns relevant context, lessons, and graph status for each phase."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "event": {
                    "type": "string",
                    "enum": ["session_start", "investigation_start", "fix_complete"],
                    "description": "Lifecycle event type",
                },
                "context": {
                    "type": "string",
                    "description": "Description of what you're doing (task, bug description, fix summary)",
                },
                "files": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Relevant files (optional)",
                },
            },
            "required": ["event"],
        },
    },
    # ── SCORCH plugin (PRO tier) ──────────────────────────────────────
    {
        "name": "graq_scorch_audit",
        "description": (
            "Run a full SCORCH v3 audit: screenshots + CSS metrics + "
            "12 behavioral UX tests + Claude Vision journey psychology. "
            "Returns pass/fail, issue list, journey score. "
            "Requires: pip install graqle[scorch] && python -m playwright install chromium"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "Base URL to audit (e.g., http://localhost:3000)",
                },
                "pages": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Page paths to audit (e.g., [\"/\", \"/pricing\"]). Default: [\"/\"]",
                },
                "config_path": {
                    "type": "string",
                    "description": "Path to SCORCH config JSON (optional, overrides url/pages)",
                },
                "skip_behavioral": {
                    "type": "boolean",
                    "default": False,
                    "description": "Skip Phase 2.5 behavioral tests",
                },
                "skip_vision": {
                    "type": "boolean",
                    "default": False,
                    "description": "Skip Phase 3 Claude Vision (saves AI cost)",
                },
                "enrich_kg": {
                    "type": "boolean",
                    "default": True,
                    "description": "Auto-add critical/major findings to the knowledge graph",
                },
            },
            "required": [],
        },
    },
    {
        "name": "graq_scorch_behavioral",
        "description": (
            "Run ONLY the 12 behavioral UX friction tests (Phase 2.5). "
            "Fast, no AI cost. Tests: dead clicks, silent submissions, "
            "unexplained jargon, ghost elements, missing CTAs, copy-paste friction, "
            "missing inline editors, incomplete generation, feature discoverability, "
            "flow continuity, upsell integrity, action-response feedback."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "Base URL to test",
                },
                "pages": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Page paths to test",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_report",
        "description": (
            "Read and summarize a SCORCH audit report from disk. "
            "Returns pass/fail status, issue counts, journey score, "
            "and top recommendations."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "report_path": {
                    "type": "string",
                    "description": "Path to report.json (default: ./scorch-output/report.json)",
                },
            },
            "required": [],
        },
    },
    # ── SCORCH Extended Skills ──
    {
        "name": "graq_scorch_a11y",
        "description": (
            "WCAG 2.1 AA/AAA accessibility audit: color contrast scanner, "
            "missing aria-labels, focus order validation, heading hierarchy, "
            "landmark structure, form labels, image alt text. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_perf",
        "description": (
            "Core Web Vitals audit: LCP, FID, CLS measurement per page, "
            "resource count/size analysis, render-blocking resource detection, "
            "DOM size, image optimization checks. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_seo",
        "description": (
            "SEO audit: title/meta description, canonical URLs, Open Graph + "
            "Twitter Card tags, structured data (JSON-LD), heading hierarchy, "
            "internal/external link analysis, image alt coverage, robots meta. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_mobile",
        "description": (
            "Mobile-specific audit: touch target sizes (44px minimum), "
            "viewport meta validation, text readability at mobile sizes, "
            "horizontal scroll detection, input type correctness, "
            "pinch-zoom restriction check. Runs on mobile viewport only. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_i18n",
        "description": (
            "Internationalization audit: html lang attribute, hardcoded strings, "
            "RTL support, date/currency formatting patterns, mixed language "
            "content detection, Unicode support checks. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_security",
        "description": (
            "Frontend security audit: CSP headers, exposed API keys (OpenAI/AWS/GitHub/Stripe), "
            "inline scripts without nonce, mixed content, insecure form actions, "
            "sensitive data in localStorage, missing security headers (HSTS, X-Frame-Options). "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_conversion",
        "description": (
            "Conversion funnel analysis: CTA inventory and placement (above/below fold), "
            "form quality analysis, trust signal detection (testimonials, badges), "
            "pricing clarity, micro-copy quality, exit-intent indicators. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_brand",
        "description": (
            "Brand consistency audit: color palette compliance against brand rules, "
            "typography adherence, font size compliance, logo presence, "
            "spacing consistency, button/link/heading style uniformity. "
            "Uses brand_rules from ScorchConfig. "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_auth_flow",
        "description": (
            "Authenticated user journey audit: tests login/signup/onboarding flows, "
            "verifies redirect behavior, session indicators, post-auth navigation. "
            "Runs both unauthenticated and authenticated (if auth_state provided). "
            "Requires: pip install graqle[scorch]"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Base URL to audit"},
                "pages": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Page paths to audit",
                },
                "auth_state": {
                    "type": "string",
                    "description": "Path to Playwright storage state JSON for authenticated session",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_scorch_diff",
        "description": (
            "Before/after SCORCH comparison: compares current report against a previous one. "
            "Shows resolved issues, new issues, persistent issues, journey score delta, "
            "severity count changes, and overall improvement percentage. "
            "No Playwright needed — works on saved report files."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "previous_report": {
                    "type": "string",
                    "description": "Path to previous report.json for comparison",
                },
                "current_report": {
                    "type": "string",
                    "description": "Path to current report.json (default: ./scorch-output/report.json)",
                },
            },
            "required": [],
        },
    },
    # ── Phantom plugin (computer skill) ──────────────────────────────
    {
        "name": "graq_phantom_browse",
        "description": (
            "Open a browser and navigate to a URL. Returns a screenshot "
            "and summarized DOM structure (buttons, links, forms, inputs, "
            "headings, landmarks). Supports authenticated sessions via "
            "saved auth profiles. This is the starting point for all "
            "Phantom computer skill interactions. "
            "Requires: pip install graqle[phantom] && python -m playwright install chromium"
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "Full URL to navigate to (e.g., https://example.com/login)",
                },
                "viewport": {
                    "type": "string",
                    "enum": ["mobile", "tablet", "desktop"],
                    "default": "desktop",
                    "description": "Viewport size: mobile (390x844), tablet (768x1024), desktop (1920x1080)",
                },
                "auth_profile": {
                    "type": "string",
                    "description": "Name of saved auth profile to use. If omitted, opens unauthenticated.",
                },
                "session_id": {
                    "type": "string",
                    "description": "Reuse an existing browser session. If omitted, creates new session.",
                },
                "wait_for": {
                    "type": "string",
                    "default": "networkidle",
                    "enum": ["load", "domcontentloaded", "networkidle", "commit"],
                    "description": "When to consider navigation complete",
                },
                "wait_after": {
                    "type": "integer",
                    "default": 2000,
                    "description": "Additional ms to wait after navigation for JS rendering",
                },
                "full_page_screenshot": {
                    "type": "boolean",
                    "default": True,
                    "description": "Capture full scrollable page vs. viewport only",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_phantom_click",
        "description": (
            "Click an element on the current page. Supports targeting by "
            "visible text content, CSS selector, or x,y coordinates. "
            "Returns the page state after the click (new URL, screenshot, "
            "any modals/dialogs that appeared). Requires an active Phantom session."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {
                    "type": "string",
                    "description": "Active Phantom session ID (from graq_phantom_browse)",
                },
                "target": {
                    "type": "string",
                    "description": (
                        "What to click. Accepts: "
                        "(1) Visible text: 'Dashboard'. "
                        "(2) CSS selector: '#submit-btn'. "
                        "(3) Coordinates: '450,300'. "
                        "(4) Role: 'role:button:Submit'."
                    ),
                },
                "click_type": {
                    "type": "string",
                    "enum": ["click", "dblclick", "right_click", "hover"],
                    "default": "click",
                },
                "wait_after": {"type": "integer", "default": 2000},
                "screenshot_after": {"type": "boolean", "default": True},
                "expect_navigation": {"type": "boolean", "default": False},
            },
            "required": ["session_id", "target"],
        },
    },
    {
        "name": "graq_phantom_type",
        "description": (
            "Type text into a form field or input element. Can target by "
            "CSS selector, label text, placeholder text, or input name. "
            "Supports clearing existing content, pressing Enter to submit, "
            "and typing with delay (to trigger autocomplete/search-as-you-type)."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string", "description": "Active Phantom session ID"},
                "target": {
                    "type": "string",
                    "description": (
                        "Input to type into. Accepts: "
                        "(1) 'placeholder:Search...', "
                        "(2) 'label:Email address', "
                        "(3) CSS selector 'input[name=email]', "
                        "(4) 'name:workspace_name', "
                        "(5) 'type:email'"
                    ),
                },
                "text": {"type": "string", "description": "Text to type"},
                "clear_first": {"type": "boolean", "default": True},
                "submit": {"type": "boolean", "default": False, "description": "Press Enter after typing"},
                "type_delay": {
                    "type": "integer", "default": 0,
                    "description": "Delay between keystrokes in ms (0=instant, 50=human-like)",
                },
                "screenshot_after": {"type": "boolean", "default": True},
            },
            "required": ["session_id", "target", "text"],
        },
    },
    {
        "name": "graq_phantom_screenshot",
        "description": (
            "Capture the current page state as a screenshot. Optionally "
            "analyze it with Claude Vision (Bedrock) to identify UX friction, "
            "visual bugs, layout issues, or answer questions about the page."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string", "description": "Active Phantom session ID"},
                "analyze": {
                    "type": "boolean", "default": False,
                    "description": "Send screenshot to Claude Vision for AI analysis (~$0.01-0.03)",
                },
                "analysis_prompt": {
                    "type": "string",
                    "description": "Custom prompt for Claude Vision analysis (only if analyze=true)",
                },
                "analysis_model": {
                    "type": "string", "default": "sonnet",
                    "enum": ["sonnet", "opus", "haiku"],
                },
                "region": {
                    "type": "string",
                    "description": "CSS selector to screenshot a specific element instead of full page",
                },
                "full_page": {"type": "boolean", "default": True},
                "mask": {
                    "type": "array", "items": {"type": "string"},
                    "description": "CSS selectors of elements to mask (blur) in screenshot",
                },
            },
            "required": ["session_id"],
        },
    },
    {
        "name": "graq_phantom_audit",
        "description": (
            "Run one or more SCORCH audit dimensions on the current page. "
            "Analyzes the live page for behavioral UX issues, accessibility "
            "violations, mobile problems, security gaps, brand inconsistency, "
            "and more. Results feed into GraQle KG. Works on any website."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string", "description": "Active Phantom session ID"},
                "dimensions": {
                    "type": "array",
                    "items": {
                        "type": "string",
                        "enum": [
                            "behavioral", "accessibility", "mobile", "security",
                            "brand", "conversion", "performance", "seo",
                            "i18n", "content", "visual", "all",
                        ],
                    },
                    "default": ["all"],
                    "description": "Audit dimensions to run. 'all' runs all 10. 'visual' requires Bedrock.",
                },
                "teach_kg": {
                    "type": "boolean", "default": True,
                    "description": "Auto-record critical/high findings to GraQle KG",
                },
                "compare_with": {
                    "type": "string",
                    "description": "Path to a previous audit JSON for improvement/regression tracking",
                },
            },
            "required": ["session_id"],
        },
    },
    {
        "name": "graq_phantom_flow",
        "description": (
            "Execute a multi-step user journey. Define a sequence of actions "
            "(navigate, click, type, wait, screenshot, assert, scroll, audit) "
            "and Phantom executes them in order, capturing screenshots at each "
            "step and recording pass/fail assertions. Works on any website."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Human-readable flow name"},
                "auth_profile": {"type": "string", "description": "Auth profile for authenticated flows"},
                "viewport": {
                    "type": "string", "enum": ["mobile", "tablet", "desktop"], "default": "desktop",
                },
                "steps": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "action": {
                                "type": "string",
                                "enum": ["navigate", "click", "type", "wait", "screenshot", "assert", "scroll", "audit"],
                            },
                            "params": {"type": "object"},
                            "description": {"type": "string"},
                        },
                        "required": ["action"],
                    },
                },
                "stop_on_failure": {"type": "boolean", "default": False},
            },
            "required": ["name", "steps"],
        },
    },
    {
        "name": "graq_phantom_discover",
        "description": (
            "Auto-discover all navigable pages from a starting URL. "
            "Crawls the sidebar, navigation menus, and in-page links to build "
            "a complete route map. Returns structured list of all discovered "
            "pages with paths, titles, and authentication requirements."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "Starting URL to begin discovery from"},
                "auth_profile": {"type": "string", "description": "Auth profile for protected pages"},
                "max_depth": {"type": "integer", "default": 3, "description": "Max navigation depth"},
                "max_pages": {"type": "integer", "default": 50, "description": "Max pages to discover"},
                "exclude_patterns": {
                    "type": "array", "items": {"type": "string"},
                    "description": "URL patterns to exclude (e.g., ['/api/', '/admin/'])",
                },
            },
            "required": ["url"],
        },
    },
    {
        "name": "graq_phantom_session",
        "description": (
            "Manage Phantom browser sessions and authentication profiles. "
            "Create sessions, save/load auth states, list active sessions, "
            "and clean up resources."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["create", "login", "save_auth", "load_auth", "list", "close", "close_all"],
                },
                "session_id": {"type": "string", "description": "Session ID for close/save ops"},
                "profile_name": {"type": "string", "description": "Auth profile name for save/load"},
                "login_url": {"type": "string", "description": "Login page URL (for 'login' action)"},
                "credentials": {
                    "type": "object",
                    "properties": {
                        "email": {"type": "string"},
                        "password": {"type": "string"},
                    },
                    "description": "Login credentials (stored only in memory, never persisted)",
                },
                "viewport": {
                    "type": "string", "enum": ["mobile", "tablet", "desktop"], "default": "desktop",
                },
            },
            "required": ["action"],
        },
    },
    # ── v0.38.0: governed code generation + editing ──────────────────
    {
        "name": "graq_edit",
        "description": (
            "Apply a governed atomic edit to a file using your project's knowledge graph. "
            "Provide a description to generate a diff, or provide a diff directly. "
            "Backup written to .graqle/edit-backup/ before any write. "
            "Default dry_run=True — never writes without explicit dry_run=False. "
            "Requires Team or Enterprise plan."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Target file to edit (must exist)",
                },
                "description": {
                    "type": "string",
                    "description": "Natural-language description of the change (used to generate diff if diff not provided)",
                },
                "diff": {
                    "type": "string",
                    "description": "Unified diff to apply directly (optional — overrides description)",
                },
                "dry_run": {
                    "type": "boolean",
                    "description": "If true, validate and preview without writing (default true — SAFE DEFAULT)",
                    "default": True,
                },
                "max_rounds": {
                    "type": "integer",
                    "description": "LLM rounds for diff generation if description is given (default 2)",
                    "default": 2,
                },
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "graq_generate",
        "description": (
            "Generate a governed code patch as a unified diff using your project's knowledge graph. "
            "Graph context activates before generation. Preflight and safety checks run automatically. "
            "Returns a CodeGenerationResult with diff patches, confidence, and audit metadata. "
            "Requires Team or Enterprise plan."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "description": {
                    "type": "string",
                    "description": "What to generate or change (e.g. 'add error handling to SyncEngine.push()')",
                },
                "file_path": {
                    "type": "string",
                    "description": "Target file path (optional — graph infers from description if omitted)",
                },
                "max_rounds": {
                    "type": "integer",
                    "description": "LLM reasoning rounds (1-5, default 2)",
                    "default": 2,
                },
                "dry_run": {
                    "type": "boolean",
                    "description": "If true, return diff preview without writing (default true)",
                    "default": True,
                },
                "stream": {
                    "type": "boolean",
                    "description": (
                        "If true, use backend streaming (agenerate_stream). "
                        "Works with any backend — Anthropic yields token-by-token, "
                        "all others yield a single chunk. Response includes a 'chunks' "
                        "field with the streamed text pieces. Default false."
                    ),
                    "default": False,
                },
            },
            "required": ["description"],
        },
    },
    # ── Phase 3.5: File system + process tools ──────────────────────────
    {
        "name": "graq_read",
        "description": (
            "Read a file's contents with optional line range. Returns file text with "
            "line numbers. Supports offset/limit for large files."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "file_path": {"type": "string", "description": "Absolute or relative path to file"},
                "offset": {"type": "integer", "description": "Start line (1-indexed, default 1)", "default": 1},
                "limit": {"type": "integer", "description": "Max lines to return (default 200)", "default": 200},
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "graq_write",
        "description": (
            "Atomically write or overwrite a file. Uses NamedTemporaryFile→fsync→os.replace. "
            "Creates parent directories if needed. dry_run=True (default) previews without writing. "
            "Patent scan runs before write — blocks if trade secrets detected."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "file_path": {"type": "string", "description": "Path to write"},
                "content": {"type": "string", "description": "Full file content to write"},
                "dry_run": {"type": "boolean", "description": "Preview only, do not write (default true)", "default": True},
            },
            "required": ["file_path", "content"],
        },
    },
    {
        "name": "graq_grep",
        "description": (
            "Search file contents by regex pattern. Returns matching lines with file path, "
            "line number, and context. Supports glob filter and case-insensitive mode."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "pattern": {"type": "string", "description": "Regex pattern to search"},
                "path": {"type": "string", "description": "Directory or file to search (default: cwd)"},
                "glob": {"type": "string", "description": "File glob filter (e.g. '*.py', '**/*.ts')"},
                "case_insensitive": {"type": "boolean", "description": "Case-insensitive match", "default": False},
                "context_lines": {"type": "integer", "description": "Lines of context before/after match", "default": 0},
                "max_results": {"type": "integer", "description": "Max matches to return", "default": 50},
            },
            "required": ["pattern"],
        },
    },
    {
        "name": "graq_glob",
        "description": (
            "Find files matching a glob pattern. Returns file paths sorted by modification time. "
            "Use for discovering files before reading or editing."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "pattern": {"type": "string", "description": "Glob pattern (e.g. 'src/**/*.py', '**/*.ts')"},
                "path": {"type": "string", "description": "Base directory (default: cwd)"},
                "max_results": {"type": "integer", "description": "Max files to return", "default": 100},
            },
            "required": ["pattern"],
        },
    },
    {
        "name": "graq_bash",
        "description": (
            "Execute a governed shell command. Enforces allowlist, timeout, and working directory. "
            "Blocked in read-only mode. Blocked commands: rm -rf, git push --force, DROP TABLE, "
            "pip install (outside venv). Returns stdout, stderr, exit_code."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "command": {"type": "string", "description": "Shell command to execute"},
                "cwd": {"type": "string", "description": "Working directory (default: project root)"},
                "timeout": {"type": "integer", "description": "Timeout in seconds (default 30, max 120)", "default": 30},
                "dry_run": {"type": "boolean", "description": "Print command without executing", "default": False},
            },
            "required": ["command"],
        },
    },
    # ── Phase 3.5: Git workflow tools ────────────────────────────────────
    {
        "name": "graq_git_status",
        "description": "Show git working tree status — changed, staged, and untracked files.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "cwd": {"type": "string", "description": "Repository directory (default: cwd)"},
            },
        },
    },
    {
        "name": "graq_git_diff",
        "description": (
            "Show git diff. By default shows unstaged changes. Pass staged=True for staged diff, "
            "or base_ref to compare against a branch/commit."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "staged": {"type": "boolean", "description": "Show staged (--cached) diff", "default": False},
                "base_ref": {"type": "string", "description": "Compare against this ref (branch, commit, tag)"},
                "file_path": {"type": "string", "description": "Limit diff to this file"},
                "cwd": {"type": "string", "description": "Repository directory"},
            },
        },
    },
    {
        "name": "graq_git_log",
        "description": "Show recent git commit history with author, date, and message.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "n": {"type": "integer", "description": "Number of commits to show (default 10)", "default": 10},
                "file_path": {"type": "string", "description": "Limit to commits touching this file"},
                "cwd": {"type": "string", "description": "Repository directory"},
            },
        },
    },
    {
        "name": "graq_git_commit",
        "description": (
            "Create a git commit with a governed message. Runs patent scan before commit — "
            "blocks if trade secrets (TS-1..TS-4) are detected in staged changes. "
            "Blocked in read-only mode."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "message": {"type": "string", "description": "Commit message"},
                "files": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Files to stage (default: all staged files)",
                },
                "dry_run": {"type": "boolean", "description": "Stage but do not commit", "default": True},
                "cwd": {"type": "string", "description": "Repository directory"},
            },
            "required": ["message"],
        },
    },
    {
        "name": "graq_git_branch",
        "description": (
            "Create or switch git branches. Follows GCC naming conventions: "
            "feature-*, hotfix-*, experiment-*, spike-*. Blocked in read-only mode."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Branch name (use feature-*, hotfix-*, experiment-* prefix)"},
                "action": {
                    "type": "string",
                    "enum": ["create", "switch", "list", "create_and_switch"],
                    "description": "Branch action",
                    "default": "create_and_switch",
                },
                "cwd": {"type": "string", "description": "Repository directory"},
            },
            "required": ["name"],
        },
    },
    # ── v0.38.0 Phase 4: Compound workflow tools ────────────────────────────
    {
        "name": "graq_review",
        "description": (
            "Perform a structured code review on a file or diff. "
            "Checks correctness, security, style, test coverage, and complexity "
            "using the knowledge graph for impact context. "
            "Returns structured comments with severity: BLOCKER, MAJOR, MINOR, INFO."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "file_path": {"type": "string", "description": "File to review (relative path)"},
                "diff": {"type": "string", "description": "Unified diff to review (alternative to file_path)"},
                "focus": {
                    "type": "string",
                    "enum": ["all", "security", "correctness", "style", "complexity", "tests"],
                    "description": "Review focus area",
                    "default": "all",
                },
                "context_depth": {
                    "type": "integer",
                    "description": "Number of graph hops to include for context (1-3)",
                    "default": 1,
                },
            },
            "required": [],
        },
    },
    {
        "name": "graq_debug",
        "description": (
            "Diagnose a bug from a stack trace, error message, or symptom description. "
            "Uses the knowledge graph call graph to trace root causes through callers and dependencies. "
            "Returns root cause analysis, affected files, and a proposed fix as unified diff."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "error": {"type": "string", "description": "Error message or stack trace"},
                "symptom": {"type": "string", "description": "Symptom description (alternative to error)"},
                "file_path": {"type": "string", "description": "File where the error occurs (narrows search)"},
                "include_fix": {
                    "type": "boolean",
                    "description": "If true, propose a fix diff. Default true.",
                    "default": True,
                },
            },
            "required": [],
        },
    },
    {
        "name": "graq_scaffold",
        "description": (
            "Scaffold a new module, class, API endpoint, or test suite from a specification. "
            "Uses existing patterns in the knowledge graph to match project conventions. "
            "Returns a set of file creation requests (dry_run by default). "
            "Blocked in read-only mode."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "spec": {
                    "type": "string",
                    "description": "What to scaffold (e.g. 'FastAPI endpoint for user authentication')",
                },
                "scaffold_type": {
                    "type": "string",
                    "enum": ["module", "class", "api_endpoint", "test_suite", "cli_command", "full_feature"],
                    "description": "Type of scaffold to generate",
                    "default": "module",
                },
                "output_dir": {"type": "string", "description": "Target directory for scaffolded files"},
                "dry_run": {
                    "type": "boolean",
                    "description": "If true (default), return file list without writing",
                    "default": True,
                },
                "with_tests": {
                    "type": "boolean",
                    "description": "If true, generate test files alongside the scaffold",
                    "default": True,
                },
            },
            "required": ["spec"],
        },
    },
    {
        "name": "graq_workflow",
        "description": (
            "Orchestrate multi-step coding workflows that chain multiple tools together. "
            "Built-in workflows: "
            "'bug_fix' (grep→read→generate→write→bash→git), "
            "'scaffold_and_test' (scaffold→generate→write→bash), "
            "'governed_refactor' (git_branch→impact→preflight→refactor→bash→gate→git). "
            "Blocked in read-only mode for write steps."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "workflow": {
                    "type": "string",
                    "enum": ["bug_fix", "scaffold_and_test", "governed_refactor", "review_and_fix"],
                    "description": "Workflow to execute",
                },
                "goal": {
                    "type": "string",
                    "description": "Natural language description of the goal for this workflow run",
                },
                "context": {
                    "type": "object",
                    "description": "Workflow-specific context (file_path, error, spec, etc.)",
                    "additionalProperties": True,
                },
                "dry_run": {
                    "type": "boolean",
                    "description": "If true, plan the workflow steps without executing write operations",
                    "default": True,
                },
                "max_steps": {
                    "type": "integer",
                    "description": "Maximum workflow steps before stopping (safety limit)",
                    "default": 10,
                },
            },
            "required": ["workflow", "goal"],
        },
    },
    # ── v0.38.0 Phase 6: graq_plan — goal decomposition + governance-gated DAG ──
    {
        "name": "graq_plan",
        "description": (
            "Decompose a high-level goal into a governance-gated DAG execution plan. "
            "Uses the knowledge graph (impact_radius, CALLS/IMPORTS edges, causal tiers) "
            "to order steps, assign risk levels, insert governance checkpoints, and "
            "estimate cost BEFORE any code runs. "
            "Returns a reviewable ExecutionPlan — does NOT execute anything. "
            "Pass the plan_id to graq_workflow to execute."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "goal": {
                    "type": "string",
                    "description": "High-level goal to decompose (e.g. 'Refactor SyncEngine.push() for error handling')",
                },
                "scope": {
                    "type": "string",
                    "description": "Optional scope constraint: file path, module name, or component name to limit impact analysis",
                    "default": "",
                },
                "max_steps": {
                    "type": "integer",
                    "description": "Maximum number of plan steps (safety limit)",
                    "default": 15,
                },
                "include_tests": {
                    "type": "boolean",
                    "description": "If true, auto-append a graq_test step at the end of the plan",
                    "default": True,
                },
                "require_approval_threshold": {
                    "type": "string",
                    "description": "Risk level at which steps require human approval: LOW | MEDIUM | HIGH | CRITICAL",
                    "default": "HIGH",
                },
                "dry_run": {
                    "type": "boolean",
                    "description": "If true, return plan skeleton without graph impact analysis (fast preview)",
                    "default": False,
                },
            },
            "required": ["goal"],
        },
    },
    # ── v0.38.0 Phase 7: graq_profile — reasoning performance profiler ────────
    {
        "name": "graq_profile",
        "description": (
            "Profile the performance of a graq_reason invocation: per-step latency, "
            "token cost, and confidence at each reasoning phase. "
            "Identifies bottleneck steps, flags slow/expensive nodes, and writes "
            "a CodeMetric KG node so future reasoning can learn from profiling history. "
            "Returns a ProfileSummary with recommendations for tuning confidence_threshold, "
            "beam_width, or context window."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The reasoning query / goal to profile",
                },
                "max_rounds": {
                    "type": "integer",
                    "description": "Max reasoning rounds to run (default 2)",
                    "default": 2,
                },
                "session_label": {
                    "type": "string",
                    "description": "Human-readable label for this profiling run (used in KG node)",
                    "default": "",
                },
                "write_kg_node": {
                    "type": "boolean",
                    "description": "If true, write CodeMetric node to KG for future calibration",
                    "default": True,
                },
                "include_step_breakdown": {
                    "type": "boolean",
                    "description": "If true, include per-step latency breakdown in response",
                    "default": True,
                },
            },
            "required": ["query"],
        },
    },
    # ── v0.38.0 Phase 5: graq_test — test execution + result parsing ────────
    {
        "name": "graq_test",
        "description": (
            "Run pytest on a target (file, directory, or test ID) and parse the results into "
            "structured CodeMetric output. "
            "Captures: pass/fail/skip counts, coverage %, failing test names, and durations. "
            "Feeds the test_coverage_gate output gate. "
            "Blocked in read-only mode (executes subprocesses)."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "target": {
                    "type": "string",
                    "description": "Test target — file path, directory, or pytest node ID (e.g. 'tests/', 'tests/test_routing.py::TestTaskRouter')",
                    "default": "tests/",
                },
                "coverage": {
                    "type": "boolean",
                    "description": "If true, run with --cov to collect coverage metrics",
                    "default": False,
                },
                "fail_fast": {
                    "type": "boolean",
                    "description": "If true, stop after first failure (-x flag)",
                    "default": False,
                },
                "cwd": {
                    "type": "string",
                    "description": "Working directory for pytest execution",
                    "default": ".",
                },
                "record_metrics": {
                    "type": "boolean",
                    "description": "If true, write CodeMetric nodes into the knowledge graph",
                    "default": False,
                },
            },
            "required": [],
        },
    },
]

# Backward-compat: register kogni_* aliases so old .mcp.json configs still work.
# Each kogni_* tool mirrors the corresponding graq_* tool exactly.
_KOGNI_ALIASES: list[dict[str, Any]] = []
for _tool in TOOL_DEFINITIONS:
    if _tool["name"].startswith("graq_") and _tool["name"] not in ("graq_reload", "graq_audit"):
        _alias = dict(_tool)
        _alias["name"] = _tool["name"].replace("graq_", "kogni_", 1)
        _KOGNI_ALIASES.append(_alias)
TOOL_DEFINITIONS.extend(_KOGNI_ALIASES)
del _KOGNI_ALIASES


# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------


_WRITE_TOOLS = frozenset({
    "graq_learn", "graq_reload", "kogni_learn",
    # v0.38.0: coding assistant write operations — blocked in read-only mode
    "graq_generate", "kogni_generate",
    "graq_edit", "kogni_edit",
    "graq_write", "kogni_write",
    "graq_bash", "kogni_bash",
    "graq_git_commit", "kogni_git_commit",
    "graq_git_branch", "kogni_git_branch",
    # Phase 4 compound workflow tools with write operations
    "graq_scaffold", "kogni_scaffold",
    "graq_workflow", "kogni_workflow",
    # Phase 5: graq_test executes subprocesses — blocked in read-only mode
    "graq_test", "kogni_test",
    # Phase 10: graq_gov_gate writes GOVERNANCE_BYPASS KG nodes — blocked in read-only mode
    "graq_gov_gate", "kogni_gov_gate",
})


class KogniDevServer:
    """MCP server exposing 7 governed development tools over stdio.

    Implements the Model Context Protocol JSON-RPC transport.
    Graph is lazily loaded on first tool call.

    Parameters
    ----------
    config_path:
        Path to graqle.yaml configuration file.
    read_only:
        If ``True``, mutation tools (graq_learn, graq_reload) are blocked.
        Only read-only tools are exposed: graq_context, graq_inspect,
        graq_reason, graq_preflight, graq_lessons, graq_impact.
    """

    def __init__(self, config_path: str = "graqle.yaml", read_only: bool = False) -> None:
        self.config_path = config_path
        self.read_only = read_only
        self._graph: Any = None  # Graqle, loaded lazily
        self._config: Any = None  # GraqleConfig
        self._graph_file: str | None = None  # path to the loaded graph JSON
        self._graph_mtime: float = 0.0
        self._gov: Any = None  # GovernanceMiddleware, loaded lazily
        self._neo4j_traversal: Any = None  # Neo4jTraversal, set when Neo4j active

    # ------------------------------------------------------------------
    # Graph lifecycle
    # ------------------------------------------------------------------

    def _load_graph(self) -> Any | None:
        """Lazy-load the knowledge graph. Reloads automatically if file changed on disk."""
        # ADR-123 Phase 1: Pull from S3 if cloud version is newer (pull-before-read).
        # Only runs on first load (self._graph is None) to avoid latency on hot-reload.
        if self._graph is None:
            try:
                from graqle.core.kg_sync import pull_if_newer, _detect_project_name
                from pathlib import Path as _Path
                _candidates = ["graqle.json", "knowledge_graph.json", "graph.json"]
                _local = next((_Path(c) for c in _candidates if _Path(c).exists()), _Path("graqle.json"))
                _proj = _detect_project_name(_local.parent.resolve())
                _result = pull_if_newer(_local, _proj)
                if _result.pulled:
                    logger.info(
                        "KG sync: pulled %d nodes from S3 (%s)",
                        _result.nodes_total,
                        _result.reason,
                    )
            except Exception as _sync_exc:
                logger.debug("KG pull-before-read skipped: %s", _sync_exc)

        # Hot-reload: check if graph file changed since last load
        if self._graph is not None and self._graph_file is not None:
            try:
                current_mtime = Path(self._graph_file).stat().st_mtime
                if current_mtime > self._graph_mtime:
                    logger.info("Graph file changed on disk — reloading")
                    self._graph = None  # Force reload
            except OSError:
                pass  # File gone — use cached graph

        if self._graph is not None:
            return self._graph

        try:
            from graqle.config.settings import GraqleConfig
            from graqle.core.graph import Graqle

            cfg_path = Path(self.config_path)
            if cfg_path.exists():
                self._config = GraqleConfig.from_yaml(str(cfg_path))
            else:
                self._config = GraqleConfig.default()

            # Try Neo4j if configured
            connector = getattr(getattr(self._config, "graph", None), "connector", "networkx")
            if connector == "neo4j":
                try:
                    graph_cfg = self._config.graph
                    self._graph = Graqle.from_neo4j(
                        uri=getattr(graph_cfg, "uri", None) or "bolt://localhost:7687",
                        username=getattr(graph_cfg, "username", None) or "neo4j",
                        password=getattr(graph_cfg, "password", None) or "",
                        database=getattr(graph_cfg, "database", None) or "neo4j",
                        config=self._config,
                    )
                    self._graph_file = f"neo4j://{getattr(graph_cfg, 'uri', 'localhost')}"
                    self._graph_mtime = 9999999999.0  # No file-based hot-reload for Neo4j
                    self._assign_backend(self._graph, self._config)
                    # Initialize Neo4j-native traversal engine for fast queries
                    try:
                        from graqle.connectors.neo4j_traversal import Neo4jTraversal
                        self._neo4j_traversal = Neo4jTraversal(
                            uri=getattr(graph_cfg, "uri", None) or "bolt://localhost:7687",
                            username=getattr(graph_cfg, "username", None) or "neo4j",
                            password=getattr(graph_cfg, "password", None) or "",
                            database=getattr(graph_cfg, "database", None) or "neo4j",
                        )
                        logger.info("Neo4j traversal engine initialized (Cypher-native)")
                    except Exception as te:
                        logger.warning("Neo4j traversal engine not available: %s", te)
                    logger.info(
                        "Loaded graph from Neo4j: %d nodes, %d edges",
                        len(self._graph.nodes),
                        len(self._graph.edges),
                    )
                    return self._graph
                except Exception as neo4j_exc:
                    logger.warning("Neo4j load failed (%s), falling back to JSON", neo4j_exc)

            # Auto-discover graph file (JSON/NetworkX fallback)
            for candidate in [
                "graqle.json",
                "knowledge_graph.json",
                "graph.json",
            ]:
                p = Path(candidate)
                if p.exists():
                    self._graph = Graqle.from_json(str(p), config=self._config)
                    self._graph_file = str(p.resolve())
                    self._graph_mtime = p.stat().st_mtime
                    # Assign real backend from config
                    self._assign_backend(self._graph, self._config)
                    logger.info(
                        "Loaded graph: %d nodes, %d edges from %s",
                        len(self._graph.nodes),
                        len(self._graph.edges),
                        candidate,
                    )
                    return self._graph

            logger.warning("No graph file found in current directory")
            return None

        except Exception as exc:
            logger.error("Failed to load graph: %s", exc)
            return None

    @staticmethod
    def _assign_backend(graph: Any, cfg: Any) -> None:
        """Create and assign a real model backend from config to the graph.

        Tries configured backend (Anthropic, OpenAI, Bedrock, Ollama).
        Falls back to mock only if real backend can't be created.
        """
        import os

        backend_name = cfg.model.backend
        model_name = cfg.model.model
        api_key = cfg.model.api_key

        # Resolve env var references like ${ANTHROPIC_API_KEY}
        if api_key and api_key.startswith("${") and api_key.endswith("}"):
            env_var = api_key[2:-1]
            api_key = os.environ.get(env_var)

        try:
            if backend_name == "anthropic":
                from graqle.backends.api import AnthropicBackend
                if not api_key:
                    api_key = os.environ.get("ANTHROPIC_API_KEY")
                if api_key:
                    graph.set_default_backend(AnthropicBackend(model=model_name, api_key=api_key))
                    logger.info("Backend: Anthropic (%s)", model_name)
                    return

            elif backend_name == "openai":
                from graqle.backends.api import OpenAIBackend
                if not api_key:
                    api_key = os.environ.get("OPENAI_API_KEY")
                if api_key:
                    graph.set_default_backend(OpenAIBackend(model=model_name, api_key=api_key))
                    logger.info("Backend: OpenAI (%s)", model_name)
                    return

            elif backend_name == "bedrock":
                from graqle.backends.api import BedrockBackend
                region = getattr(cfg.model, "region", None) or os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") or "eu-central-1"
                graph.set_default_backend(BedrockBackend(model=model_name, region=region))
                logger.info("Backend: Bedrock (%s in %s)", model_name, region)
                return

            elif backend_name == "ollama":
                from graqle.backends.api import OllamaBackend
                host = getattr(cfg.model, "host", None) or "http://localhost:11434"
                graph.set_default_backend(OllamaBackend(model=model_name, host=host))
                logger.info("Backend: Ollama (%s)", model_name)
                return

        except Exception as exc:
            logger.warning("Failed to create %s backend: %s", backend_name, exc)

        # No real backend available — LOUD warning
        logger.warning(
            "NO LLM BACKEND CONFIGURED — reasoning will use graph traversal only. "
            "Run 'graq doctor' to diagnose. "
            "Quick fix: export ANTHROPIC_API_KEY=sk-ant-..."
        )

    def _require_graph(self) -> Any:
        """Load graph or raise with a helpful message."""
        graph = self._load_graph()
        if graph is None:
            raise RuntimeError(
                "No knowledge graph loaded. "
                "Place a graqle.json, knowledge_graph.json, or graph.json "
                "in the working directory, or run 'graq scan --repo .' first."
            )
        return graph

    # ------------------------------------------------------------------
    # Node search helpers
    # ------------------------------------------------------------------

    def _find_node(self, name: str) -> Any | None:
        """Find node by exact ID, label, or fuzzy substring match."""
        graph = self._require_graph()
        if not name:
            return None

        # Exact ID
        if name in graph.nodes:
            return graph.nodes[name]

        # Case-insensitive label match
        name_lower = name.lower()
        for node in graph.nodes.values():
            if node.label.lower() == name_lower:
                return node

        # Substring match on id or label
        for node in graph.nodes.values():
            if name_lower in node.label.lower() or name_lower in node.id.lower():
                return node

        return None

    def _find_nodes_matching(self, text: str, *, limit: int = 20) -> list[Any]:
        """Find all nodes whose label/id/description fuzzy-match *text*."""
        graph = self._require_graph()
        text_lower = text.lower()
        tokens = text_lower.split()

        scored: list[tuple[float, Any]] = []
        for node in graph.nodes.values():
            haystack = f"{node.id} {node.label} {node.entity_type} {node.description[:200]}".lower()
            score = sum(1.0 for tok in tokens if tok in haystack)
            if score > 0:
                scored.append((score, node))

        scored.sort(key=lambda x: x[0], reverse=True)
        return [node for _, node in scored[:limit]]

    def _get_neighbor_summaries(self, node_id: str) -> list[dict[str, str]]:
        """Return compact neighbor info for a node.

        Uses Neo4j-native Cypher query when available (~2ms).
        Falls back to Python edge iteration (~10ms).
        """
        # Fast path: Neo4j-native neighbor query
        if getattr(self, "_neo4j_traversal", None) is not None:
            try:
                ctx = self._neo4j_traversal.node_context(node_id, max_neighbors=30)
                if ctx.get("found"):
                    return ctx.get("neighbors", [])
            except Exception:
                pass  # Fall through to Python path

        # Fallback: Python iteration
        graph = self._require_graph()
        neighbors: list[dict[str, str]] = []
        seen: set[str] = set()

        for edge in graph.edges.values():
            other_id: str | None = None
            if edge.source_id == node_id:
                other_id = edge.target_id
            elif edge.target_id == node_id:
                other_id = edge.source_id

            if other_id and other_id not in seen:
                seen.add(other_id)
                other = graph.nodes.get(other_id)
                if other:
                    neighbors.append({
                        "id": other.id,
                        "label": other.label,
                        "type": other.entity_type,
                        "relationship": edge.relationship,
                    })
        return neighbors

    def _redact(self, props: dict[str, Any]) -> dict[str, Any]:
        """Remove sensitive properties."""
        return {
            k: v for k, v in props.items()
            if k.lower() not in _SENSITIVE_KEYS and k != "chunks"
        }

    # ------------------------------------------------------------------
    # MCP protocol: tool listing
    # ------------------------------------------------------------------

    def list_tools(self) -> list[dict[str, Any]]:
        """Return MCP tool definitions.

        In read-only mode, mutation tools (graq_learn, graq_reload) are excluded.
        """
        if self.read_only:
            return [t for t in TOOL_DEFINITIONS if t["name"] not in _WRITE_TOOLS]
        return TOOL_DEFINITIONS

    # ------------------------------------------------------------------
    # MCP protocol: tool dispatch
    # ------------------------------------------------------------------

    async def handle_tool(self, name: str, arguments: dict[str, Any]) -> str:
        """Route a tool call to the correct handler. Returns JSON string."""
        # Block write tools in read-only mode
        if self.read_only and name in _WRITE_TOOLS:
            return json.dumps({
                "error": f"Tool '{name}' is blocked in read-only mode. "
                "The MCP server was started with --read-only.",
            })

        handlers: dict[str, Any] = {
            "graq_context": self._handle_context,
            "graq_inspect": self._handle_inspect,
            "graq_reason": self._handle_reason,
            "graq_reason_batch": self._handle_reason_batch,
            "graq_preflight": self._handle_preflight,
            "graq_lessons": self._handle_lessons,
            "graq_impact": self._handle_impact,
            "graq_safety_check": self._handle_safety_check,
            "graq_learn": self._handle_learn,
            "graq_predict": self._handle_predict,
            "graq_reload": self._handle_reload,
            "graq_audit": self._handle_audit,
            "graq_runtime": self._handle_runtime,
            "graq_route": self._handle_route,
            "graq_lifecycle": self._handle_lifecycle,
            "graq_gate": self._handle_gate,
            "graq_gov_gate": self._handle_gov_gate,
            "graq_drace": self._handle_drace,
            # SCORCH plugin
            "graq_scorch_audit": self._handle_scorch_audit,
            "graq_scorch_behavioral": self._handle_scorch_behavioral,
            "graq_scorch_report": self._handle_scorch_report,
            "graq_scorch_a11y": self._handle_scorch_a11y,
            "graq_scorch_perf": self._handle_scorch_perf,
            "graq_scorch_seo": self._handle_scorch_seo,
            "graq_scorch_mobile": self._handle_scorch_mobile,
            "graq_scorch_i18n": self._handle_scorch_i18n,
            "graq_scorch_security": self._handle_scorch_security,
            "graq_scorch_conversion": self._handle_scorch_conversion,
            "graq_scorch_brand": self._handle_scorch_brand,
            "graq_scorch_auth_flow": self._handle_scorch_auth_flow,
            "graq_scorch_diff": self._handle_scorch_diff,
            # Phantom plugin (computer skill)
            "graq_phantom_browse": self._handle_phantom_browse,
            "graq_phantom_click": self._handle_phantom_click,
            "graq_phantom_type": self._handle_phantom_type,
            "graq_phantom_screenshot": self._handle_phantom_screenshot,
            "graq_phantom_audit": self._handle_phantom_audit,
            "graq_phantom_flow": self._handle_phantom_flow,
            "graq_phantom_discover": self._handle_phantom_discover,
            "graq_phantom_session": self._handle_phantom_session,
            # v0.38.0: governed code generation + editing
            "graq_edit": self._handle_edit,
            "kogni_edit": self._handle_edit,
            "graq_generate": self._handle_generate,
            "kogni_generate": self._handle_generate,
            # Backward-compat aliases (kogni_* → graq_*)
            "kogni_context": self._handle_context,
            "kogni_inspect": self._handle_inspect,
            "kogni_reason": self._handle_reason,
            "kogni_reason_batch": self._handle_reason_batch,
            "kogni_preflight": self._handle_preflight,
            "kogni_lessons": self._handle_lessons,
            "kogni_impact": self._handle_impact,
            "kogni_safety_check": self._handle_safety_check,
            "kogni_learn": self._handle_learn,
            "kogni_predict": self._handle_predict,
            "kogni_runtime": self._handle_runtime,
            "kogni_route": self._handle_route,
            "kogni_lifecycle": self._handle_lifecycle,
            "kogni_gate": self._handle_gate,
            "kogni_gov_gate": self._handle_gov_gate,
            "kogni_drace": self._handle_drace,
            # Phantom kogni aliases
            "kogni_phantom_browse": self._handle_phantom_browse,
            "kogni_phantom_click": self._handle_phantom_click,
            "kogni_phantom_type": self._handle_phantom_type,
            "kogni_phantom_screenshot": self._handle_phantom_screenshot,
            "kogni_phantom_audit": self._handle_phantom_audit,
            "kogni_phantom_flow": self._handle_phantom_flow,
            "kogni_phantom_discover": self._handle_phantom_discover,
            "kogni_phantom_session": self._handle_phantom_session,
            # v0.38.0 Phase 3.5: file system + git tools
            "graq_read": self._handle_read,
            "kogni_read": self._handle_read,
            "graq_write": self._handle_write,
            "kogni_write": self._handle_write,
            "graq_grep": self._handle_grep,
            "kogni_grep": self._handle_grep,
            "graq_glob": self._handle_glob,
            "kogni_glob": self._handle_glob,
            "graq_bash": self._handle_bash,
            "kogni_bash": self._handle_bash,
            "graq_git_status": self._handle_git_status,
            "kogni_git_status": self._handle_git_status,
            "graq_git_diff": self._handle_git_diff,
            "kogni_git_diff": self._handle_git_diff,
            "graq_git_log": self._handle_git_log,
            "kogni_git_log": self._handle_git_log,
            "graq_git_commit": self._handle_git_commit,
            "kogni_git_commit": self._handle_git_commit,
            "graq_git_branch": self._handle_git_branch,
            "kogni_git_branch": self._handle_git_branch,
            # v0.38.0 Phase 4: compound workflow tools
            "graq_review": self._handle_review,
            "kogni_review": self._handle_review,
            "graq_debug": self._handle_debug,
            "kogni_debug": self._handle_debug,
            "graq_scaffold": self._handle_scaffold,
            "kogni_scaffold": self._handle_scaffold,
            "graq_workflow": self._handle_workflow,
            "kogni_workflow": self._handle_workflow,
            # v0.38.0 Phase 5: test execution
            "graq_test": self._handle_test,
            "kogni_test": self._handle_test,
            # v0.38.0 Phase 6: agent planning (read-only — never executes)
            "graq_plan": self._handle_plan,
            "kogni_plan": self._handle_plan,
            # v0.38.0 Phase 7: performance profiler
            "graq_profile": self._handle_profile,
            "kogni_profile": self._handle_profile,
        }

        handler = handlers.get(name)
        if handler is None:
            return json.dumps({"error": f"Unknown tool: {name}"})

        # Track caller in metrics if provided
        caller = arguments.get("caller", "")
        if caller:
            try:
                from graqle.metrics.engine import MetricsEngine
                metrics = MetricsEngine()
                metrics.record_query(f"mcp:{name}", 0, caller=caller)
            except Exception:
                pass  # Never fail on metrics tracking

        try:
            return await handler(arguments)
        except Exception as exc:
            logger.exception("Tool %s failed", name)
            return json.dumps({"error": str(exc)})


    # ==================================================================
    # Tool handlers
    # ==================================================================

    # ── 1. graq_context (FREE) ───────────────────────────────────────

    async def _handle_context(self, args: dict[str, Any]) -> str:
        task = args.get("task", "")
        level = args.get("level", "standard")

        if not task:
            return json.dumps({"error": "Parameter 'task' is required."})

        graph = self._load_graph()

        parts: list[str] = []

        # ---- Active branch (from .gcc/registry.md) --------------------
        branch_info = self._read_active_branch()
        if branch_info:
            parts.append(f"## Active Branch\n{branch_info}")

        # ---- Relevant graph nodes -------------------------------------
        if graph is not None:
            matches = self._find_nodes_matching(task, limit=_context_limit(level))
            if matches:
                parts.append("## Relevant Nodes")
                for node in matches:
                    desc_limit = 80 if level == "minimal" else 200 if level == "standard" else 400
                    desc = node.description[:desc_limit]
                    line = f"- **{node.label}** ({node.entity_type}): {desc}"
                    parts.append(line)

                    if level == "deep":
                        neighbors = self._get_neighbor_summaries(node.id)
                        for nb in neighbors[:3]:
                            parts.append(
                                f"  - [{nb['relationship']}] {nb['label']} ({nb['type']})"
                            )

            # ---- Lessons / safety nodes -------------------------------
            lessons = self._find_lesson_nodes(task, severity_filter="critical")
            if lessons:
                parts.append("## Applicable Lessons")
                for lesson in lessons[:5]:
                    parts.append(f"- [{lesson['severity']}] {lesson['label']}: {lesson['description']}")
        else:
            parts.append(
                "_No knowledge graph loaded. "
                "Run `graq scan --repo .` to build one._"
            )

        if not parts:
            parts.append(f"No specific context found for task: '{task}'")

        return json.dumps({
            "context": "\n\n".join(parts),
            "level": level,
            "nodes_matched": len(matches) if graph and matches else 0,
            "graph_loaded": graph is not None,
        })

    # ── 2. graq_inspect (FREE) ───────────────────────────────────────

    async def _handle_inspect(self, args: dict[str, Any]) -> str:
        node_id = args.get("node_id")
        show_stats = args.get("stats", False)

        graph = self._require_graph()

        # Single node inspection
        if node_id:
            node = self._find_node(node_id)
            if node is None:
                return json.dumps({
                    "error": f"Node '{node_id}' not found.",
                    "hint": "Use graq_inspect with stats=true to see available nodes.",
                })
            neighbors = self._get_neighbor_summaries(node.id)
            props = self._redact(node.properties)
            return json.dumps({
                "id": node.id,
                "label": node.label,
                "type": node.entity_type,
                "description": node.description[:500],
                "degree": node.degree,
                "properties": props,
                "neighbors": neighbors,
                "status": node.status.value if hasattr(node.status, "value") else str(node.status),
            })

        # Full graph stats
        if show_stats:
            stats = graph.stats
            type_counts: dict[str, int] = {}
            for n in graph.nodes.values():
                t = n.entity_type
                type_counts[t] = type_counts.get(t, 0) + 1

            return json.dumps({
                "total_nodes": stats.total_nodes,
                "total_edges": stats.total_edges,
                "avg_degree": round(stats.avg_degree, 2),
                "density": round(stats.density, 4),
                "connected_components": stats.connected_components,
                "hub_nodes": stats.hub_nodes[:10],
                "entity_types": type_counts,
                "graph_file": self._graph_file,
            })

        # Default: compact node listing
        nodes_list = []
        for nid, node in list(graph.nodes.items())[:_MAX_RESULTS]:
            nodes_list.append({
                "id": nid,
                "label": node.label,
                "type": node.entity_type,
                "degree": node.degree,
            })

        return json.dumps({
            "nodes": nodes_list,
            "total": len(graph.nodes),
            "shown": len(nodes_list),
        })

    # ── 3. graq_reason (FREE) ────────────────────────────────────────

    async def _handle_reason(self, args: dict[str, Any]) -> str:
        import time as _time

        question = args.get("question", "")
        max_rounds = min(max(args.get("max_rounds", 2), 1), 5)

        if not question:
            return json.dumps({"error": "Parameter 'question' is required."})

        t0 = _time.monotonic()
        graph = self._require_graph()

        # Detect backend status BEFORE attempting reasoning
        backend_status = self._check_backend_status(graph)

        # ADR-112: NO SILENT FALLBACK. If reasoning fails, return a hard error.
        # Keyword traversal is NOT reasoning. Pretending it is destroys user trust.
        # graq_inspect exists for keyword lookup. graq_reason MUST use LLM.
        try:
            result = await graph.areason(
                question, max_rounds=max_rounds, task_type="reason",
            )
            result_dict = {
                "answer": result.answer,
                "confidence": round(result.confidence, 3),
                "rounds": result.rounds_completed,
                "nodes_used": result.node_count,
                "active_nodes": result.active_nodes[:10],
                "cost_usd": round(result.cost_usd, 6),
                "latency_ms": round(result.latency_ms, 1),
                "mode": result.reasoning_mode,
                "backend_status": result.backend_status,
                "backend_error": result.backend_error,
            }
            duration_ms = (_time.monotonic() - t0) * 1000

            # Governance audit
            gov = self._get_governance()
            if gov is not None:
                try:
                    session = gov.get_or_start_session(f"reason:{question[:50]}")
                    gov.log_tool_call(
                        session, "graq_reason", args, result_dict,
                        duration_ms=duration_ms,
                        nodes_consulted=result.node_count,
                    )
                except Exception as exc:
                    logger.debug("Governance logging failed: %s", exc)

            # Fire-and-forget metrics push (team plan only, non-blocking)
            try:
                from graqle.cloud.metrics_push import push_reasoning_metrics
                push_reasoning_metrics(
                    tool_name="graq_reason",
                    latency_ms=result.latency_ms,
                    confidence=result.confidence,
                    rounds=result.rounds_completed,
                    node_count=result.node_count,
                    cost_usd=result.cost_usd,
                )
            except Exception:
                pass

            return json.dumps(result_dict)

        except (RuntimeError, Exception) as exc:
            # ADR-112: Hard failure — NO keyword fallback.
            # User must know reasoning is broken and fix it.
            err = str(exc)[:300]
            logger.error("graq_reason FAILED (no fallback per ADR-112): %s", err)
            cfg_backend = getattr(getattr(self._config, "model", None), "backend", "unknown")
            cfg_model = getattr(getattr(self._config, "model", None), "model", "unknown")
            cfg_region = getattr(getattr(self._config, "model", None), "region", "unknown")
            return json.dumps({
                "error": "REASONING_BACKEND_UNAVAILABLE",
                "message": (
                    f"graq_reason requires a working LLM backend. "
                    f"Backend '{cfg_backend}' ({cfg_model} in {cfg_region}) failed: {err}"
                ),
                "fix": (
                    "1. Run 'graq doctor' to diagnose. "
                    "2. Check graqle.yaml model.backend, model.model, model.region. "
                    "3. For Bedrock: verify AWS credentials and that the cross-region "
                    "   inference profile is enabled in your account. "
                    "4. Reload Claude Code after fixing graqle.yaml."
                ),
                "mode": "error",
                "confidence": 0.0,
                "backend_error": err,
                "hint": "graq_inspect is available for keyword-only node lookup if needed.",
            })

    # ── 3b. graq_reason_batch (PRO) ──────────────────────────────────

    async def _handle_reason_batch(self, args: dict[str, Any]) -> str:
        """Handle batch reasoning — multiple questions in parallel.

        mode="standard" (default): standard reasoning for each question.
        mode="predictive": runs each query through the PSE pipeline independently.
                           fold_back applies to the entire batch (all-or-nothing).
        """
        import asyncio as _asyncio
        import time as _time

        questions = args.get("questions", [])
        mode = args.get("mode", "standard")
        max_rounds = min(max(args.get("max_rounds", 2), 1), 5)
        max_concurrent = min(max(args.get("max_concurrent", 5), 1), 10)
        fold_back = args.get("fold_back", True)
        confidence_threshold = args.get("confidence_threshold", 0.65)

        if not questions or not isinstance(questions, list):
            return json.dumps({"error": "Parameter 'questions' (list of strings) is required."})

        t0 = _time.monotonic()

        if mode == "predictive":
            # Run each question through predict pipeline independently
            semaphore = _asyncio.Semaphore(max_concurrent)

            async def _run_predict(question: str) -> dict[str, Any]:
                async with semaphore:
                    predict_args = {
                        "query": question,
                        "mode": "compound",
                        "max_rounds": max_rounds,
                        "fold_back": fold_back,
                        "confidence_threshold": confidence_threshold,
                    }
                    raw = await self._handle_predict(predict_args)
                    try:
                        return json.loads(raw) if isinstance(raw, str) else raw
                    except Exception:
                        return {"question": question, "error": "parse failed"}

            batch_results = await _asyncio.gather(*[_run_predict(q) for q in questions])
            total_ms = (_time.monotonic() - t0) * 1000

            return json.dumps({
                "batch_size": len(questions),
                "mode": "predictive",
                "total_latency_ms": round(total_ms, 1),
                "total_cost_usd": round(
                    sum(r.get("cost_usd", 0) for r in batch_results), 6
                ),
                "results": [
                    {**r, "question": q}
                    for q, r in zip(questions, batch_results)
                ],
            })

        # Standard mode — existing behaviour unchanged
        graph = self._require_graph()
        results = await graph.areason_batch(
            questions, max_rounds=max_rounds, max_concurrent=max_concurrent,
        )
        total_ms = (_time.monotonic() - t0) * 1000

        return json.dumps({
            "batch_size": len(questions),
            "mode": "standard",
            "total_latency_ms": round(total_ms, 1),
            "total_cost_usd": round(sum(r.cost_usd for r in results), 6),
            "avg_confidence": round(
                sum(r.confidence for r in results) / len(results), 3
            ) if results else 0,
            "results": [
                {
                    "question": q,
                    "answer": r.answer,
                    "confidence": round(r.confidence, 3),
                    "nodes_used": r.node_count,
                    "cost_usd": round(r.cost_usd, 6),
                    "mode": r.reasoning_mode,
                }
                for q, r in zip(questions, results)
            ],
        })

    # ── 4. graq_preflight (PRO) ──────────────────────────────────────

    async def _handle_preflight(self, args: dict[str, Any]) -> str:
        action = args.get("action", "")
        files = args.get("files", [])

        if not action:
            return json.dumps({"error": "Parameter 'action' is required."})

        graph = self._load_graph()
        report: dict[str, Any] = {
            "action": action,
            "files": files,
            "warnings": [],
            "lessons": [],
            "safety_boundaries": [],
            "adrs": [],
            "risk_level": "low",
        }

        if graph is None:
            report["warnings"].append(
                "No knowledge graph loaded — preflight is limited to file-based checks."
            )
            return json.dumps(report)

        # Search for related lessons, mistakes, safety nodes
        search_text = action + " " + " ".join(files)
        lessons = self._find_lesson_nodes(search_text, severity_filter="all")

        for lesson in lessons:
            entry = {
                "id": lesson.get("id", lesson["label"]),
                "label": lesson["label"],
                "severity": lesson["severity"],
                "description": lesson["description"],
                "type": lesson["entity_type"],
                "hit_count": lesson.get("hit_count", 0),
                "relevance_score": lesson.get("score", 0),
            }
            if lesson["entity_type"] in ("SAFETY", "SAFETY_BOUNDARY"):
                report["safety_boundaries"].append(entry)
            elif lesson["entity_type"] in ("ADR", "DECISION"):
                report["adrs"].append(entry)
            else:
                report["lessons"].append(entry)

        # Check if changed files match any known component nodes
        for fpath in files:
            fname = Path(fpath).name.lower()
            stem = Path(fpath).stem.lower()
            for node in graph.nodes.values():
                node_text = f"{node.id} {node.label}".lower()
                if stem in node_text or fname in node_text:
                    neighbors = self._get_neighbor_summaries(node.id)
                    if neighbors:
                        report["warnings"].append(
                            f"File '{fpath}' relates to node '{node.label}' "
                            f"which connects to {len(neighbors)} other components."
                        )

        # Compute risk level
        n_critical = sum(
            1 for l in report["lessons"]
            if l.get("severity") in ("CRITICAL", "critical")
        )
        n_safety = len(report["safety_boundaries"])
        if n_critical > 0 or n_safety > 0:
            report["risk_level"] = "high"
        elif len(report["lessons"]) > 2 or len(report["warnings"]) > 2:
            report["risk_level"] = "medium"

        return json.dumps(report)

    # ── 5. graq_lessons (PRO) ────────────────────────────────────────

    async def _handle_lessons(self, args: dict[str, Any]) -> str:
        operation = args.get("operation", "")
        severity_filter = args.get("severity_filter", "high")

        if not operation:
            return json.dumps({"error": "Parameter 'operation' is required."})

        lessons = self._find_lesson_nodes(operation, severity_filter=severity_filter)

        return json.dumps({
            "operation": operation,
            "filter": severity_filter,
            "count": len(lessons),
            "lessons": lessons,
        })

    # ── 6. graq_impact (PRO) ─────────────────────────────────────────

    # Node types excluded from impact results when code_only is True
    _NON_CODE_TYPES: frozenset[str] = frozenset({
        "Document", "DOCUMENT", "Section", "SECTION", "Chunk", "CHUNK",
        "Paragraph", "PARAGRAPH", "Directory", "Config", "EnvVar",
        "DatabaseModel", "DockerService", "CIPipeline",
    })

    async def _handle_impact(self, args: dict[str, Any]) -> str:
        component = args.get("component", "")
        change_type = args.get("change_type", "modify")
        code_only = args.get("code_only", False)

        if not component:
            return json.dumps({"error": "Parameter 'component' is required."})

        graph = self._require_graph()
        start_node = self._find_node(component)

        if start_node is None:
            # Try fuzzy match
            matches = self._find_nodes_matching(component, limit=3)
            if matches:
                return json.dumps({
                    "error": f"Component '{component}' not found exactly.",
                    "suggestions": [
                        {"id": m.id, "label": m.label, "type": m.entity_type}
                        for m in matches
                    ],
                })
            return json.dumps({"error": f"Component '{component}' not found in graph."})

        # BFS traversal for impact
        impact_tree = self._bfs_impact(
            start_node.id, change_type=change_type, max_depth=_MAX_BFS_DEPTH
        )

        # Filter non-code nodes if requested
        total_before_filter = len(impact_tree)
        if code_only:
            impact_tree = [
                n for n in impact_tree
                if n.get("type", "") not in self._NON_CODE_TYPES
            ]

        # Risk summary
        risk_scores = {"remove": 3, "deploy": 2, "modify": 1, "add": 0.5}
        base_risk = risk_scores.get(change_type, 1)
        total_affected = len(impact_tree)
        overall_risk = "low"
        if total_affected > 5 or base_risk >= 3:
            overall_risk = "high"
        elif total_affected > 2 or base_risk >= 2:
            overall_risk = "medium"

        result = {
            "component": start_node.label,
            "change_type": change_type,
            "overall_risk": overall_risk,
            "affected_count": total_affected,
            "impact_tree": impact_tree,
        }
        if code_only:
            result["filtered_out"] = total_before_filter - total_affected
        return json.dumps(result)

    # Structural edges that should NOT propagate impact (they connect siblings
    # via parent directories, producing "everything in components/" results).
    _STRUCTURAL_EDGES: set[str] = {"CONTAINS", "DEFINES"}

    def _bfs_impact(
        self,
        start_id: str,
        *,
        change_type: str = "modify",
        max_depth: int = _MAX_BFS_DEPTH,
    ) -> list[dict[str, Any]]:
        """BFS from start_id, following only dependency edges (not structural).

        Uses Neo4j-native Cypher traversal when available (~5ms).
        Falls back to Python BFS over in-memory graph (~60ms).
        """
        # Fast path: Neo4j-native traversal
        if getattr(self, "_neo4j_traversal", None) is not None:
            try:
                return self._neo4j_traversal.impact_bfs(
                    start_id,
                    max_depth=max_depth,
                    change_type=change_type,
                    limit=_MAX_RESULTS,
                )
            except Exception as exc:
                logger.warning("Neo4j traversal failed (%s), falling back to Python BFS", exc)

        # Fallback: Python BFS over in-memory graph
        graph = self._require_graph()
        visited: set[str] = {start_id}
        queue: deque[tuple[str, int, str]] = deque()  # (node_id, depth, relationship)

        # Seed: who *depends on* start_id?  Follow reverse IMPORTS/CALLS, and
        # forward edges from start_id that are dependency-typed.
        for edge in graph.edges.values():
            rel = getattr(edge, "relationship", "")
            if rel in self._STRUCTURAL_EDGES:
                continue
            if edge.source_id == start_id and edge.target_id not in visited:
                queue.append((edge.target_id, 1, rel))
            elif edge.target_id == start_id and edge.source_id not in visited:
                queue.append((edge.source_id, 1, rel))

        results: list[dict[str, Any]] = []

        while queue:
            nid, depth, rel = queue.popleft()
            if nid in visited or depth > max_depth:
                continue
            visited.add(nid)

            node = graph.nodes.get(nid)
            if node is None:
                continue

            # Risk assessment per node
            risk = "low"
            if change_type == "remove":
                risk = "high" if depth <= 1 else "medium"
            elif change_type == "deploy":
                risk = "medium" if depth <= 1 else "low"
            elif change_type == "modify":
                risk = "medium" if depth == 1 else "low"

            results.append({
                "id": nid,
                "label": node.label,
                "type": node.entity_type,
                "depth": depth,
                "relationship": rel,
                "risk": risk,
            })

            # Continue BFS — only follow dependency edges
            if depth < max_depth:
                for edge in graph.edges.values():
                    edge_rel = getattr(edge, "relationship", "")
                    if edge_rel in self._STRUCTURAL_EDGES:
                        continue
                    next_id: str | None = None
                    if edge.source_id == nid and edge.target_id not in visited:
                        next_id = edge.target_id
                    elif edge.target_id == nid and edge.source_id not in visited:
                        next_id = edge.source_id
                    if next_id:
                        queue.append((next_id, depth + 1, edge_rel))

        return results

    # ── 6b. graq_safety_check (PRO) ──────────────────────────────────

    async def _handle_safety_check(self, args: dict[str, Any]) -> str:
        """Combined safety check: impact → preflight → reasoning (if risk warrants)."""
        component = args.get("component", "")
        change_type = args.get("change_type", "modify")
        skip_reasoning = args.get("skip_reasoning", False)

        if not component:
            return json.dumps({"error": "Parameter 'component' is required."})

        # Step 1: Impact
        impact_result = json.loads(await self._handle_impact({
            "component": component,
            "change_type": change_type,
            "code_only": True,
        }))

        # Step 2: Preflight
        affected = [n.get("label", "") for n in impact_result.get("impact_tree", [])[:5]]
        preflight_result = json.loads(await self._handle_preflight({
            "action": f"{change_type} {component}",
            "files": affected,
        }))

        # Step 3: Reasoning (only if risk warrants it)
        reasoning_result = None
        risk_level = preflight_result.get("risk_level", "low")
        if not skip_reasoning and risk_level in ("medium", "high"):
            try:
                reasoning_result = json.loads(await self._handle_reason({
                    "question": (
                        f"What are the risks of {change_type}ing {component}? "
                        f"Affected: {', '.join(affected[:3])}. Risk: {risk_level}."
                    ),
                    "max_rounds": 2,
                }))
            except Exception as exc:
                reasoning_result = {"error": str(exc)[:200]}

        return json.dumps({
            "component": component,
            "change_type": change_type,
            "overall_risk": risk_level,
            "impact": impact_result,
            "preflight": preflight_result,
            "reasoning": reasoning_result,
        })

    # ── 7. graq_predict (PRO) ────────────────────────────────────────

    async def _handle_predict(self, args: dict[str, Any]) -> str:
        """Delegate to MCPServer._handle_predict (Layer A fold-back logic lives there)."""
        from graqle.plugins.mcp_server import MCPServer, MCPConfig
        if self._graph is None:
            self._load_graph()  # make sure dev server graph is loaded first
        cfg = MCPConfig(graph_path="graqle.json")
        proxy = MCPServer(cfg)
        proxy._graph = self._graph        # inject already-loaded graph — skips _ensure_graph
        proxy._embedder = getattr(self, "_embedder", None)
        result = await proxy._handle_predict(args)
        # MCPToolResult.content is already a JSON string — return it directly
        content = getattr(result, "content", None)
        if content and isinstance(content, str) and content.strip():
            return content
        return json.dumps({"error": "graq_predict returned empty result"})

    # ── 8. graq_learn (PRO) ──────────────────────────────────────────

    async def _handle_learn(self, args: dict[str, Any]) -> str:
        mode = args.get("mode", "outcome")

        if mode == "entity":
            return await self._handle_learn_entity(args)
        elif mode == "knowledge":
            return await self._handle_learn_knowledge(args)
        else:
            return await self._handle_learn_outcome(args)

    async def _handle_learn_outcome(self, args: dict[str, Any]) -> str:
        """Original learn mode: record dev outcomes, adjust edge weights."""
        action = args.get("action", "")
        outcome = args.get("outcome", "")
        components = args.get("components", [])
        lesson_text = args.get("lesson")

        if not action or not outcome or not components:
            return json.dumps({
                "error": "Outcome mode requires 'action', 'outcome', and 'components'."
            })

        graph = self._load_graph()
        updates: list[dict[str, Any]] = []
        lesson_node_id: str | None = None

        if graph is not None:
            weight_delta = {
                "success": 0.05,
                "failure": -0.1,
                "partial": -0.02,
            }.get(outcome, 0.0)

            component_ids: list[str] = []
            for comp in components:
                node = self._find_node(comp)
                if node:
                    component_ids.append(node.id)

            for i, nid_a in enumerate(component_ids):
                for nid_b in component_ids[i + 1:]:
                    edges = graph.get_edges_between(nid_a, nid_b)
                    for edge in edges:
                        old_weight = edge.weight
                        edge.weight = max(0.01, min(2.0, edge.weight + weight_delta))
                        updates.append({
                            "edge": edge.id,
                            "from": nid_a,
                            "to": nid_b,
                            "old_weight": round(old_weight, 4),
                            "new_weight": round(edge.weight, 4),
                            "delta": round(weight_delta, 4),
                        })

            if lesson_text:
                from graqle.core.edge import CogniEdge
                from graqle.core.node import CogniNode

                ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
                lesson_node_id = f"lesson_{ts}"
                severity = "HIGH" if outcome == "failure" else "MEDIUM"

                lesson_node = CogniNode(
                    id=lesson_node_id,
                    label=lesson_text[:80],
                    entity_type="LESSON",
                    description=lesson_text,
                    properties={
                        "severity": severity,
                        "outcome": outcome,
                        "action": action,
                        "hit_count": 0,
                        "created": ts,
                    },
                )
                graph.add_node(lesson_node)

                for idx, nid in enumerate(component_ids):
                    edge = CogniEdge(
                        id=f"e_{lesson_node_id}_{nid}_{idx}",
                        source_id=lesson_node_id,
                        target_id=nid,
                        relationship="LEARNED_FROM",
                        weight=1.0,
                    )
                    graph.add_edge(edge)

            self._save_graph(graph)

        return json.dumps({
            "recorded": True,
            "mode": "outcome",
            "action": action,
            "outcome": outcome,
            "components": components,
            "edge_updates": updates,
            "lesson_node_id": lesson_node_id,
        })

    async def _handle_learn_entity(self, args: dict[str, Any]) -> str:
        """Entity mode: add business-level nodes (PRODUCT, CLIENT, etc.)."""
        entity_id = args.get("entity_id", "")
        entity_type = args.get("entity_type", "PRODUCT")
        description = args.get("description", "")
        connects_to = args.get("connects_to", [])

        if not entity_id:
            return json.dumps({
                "error": "Entity mode requires 'entity_id'."
            })

        graph = self._load_graph()
        if graph is None:
            return json.dumps({"error": "No graph loaded."})

        graph.add_node_simple(
            entity_id,
            label=entity_id.replace("_", " ").title(),
            entity_type=entity_type.upper(),
            description=description,
            properties={
                "source": "graq_learn_entity",
                "manual": True,
                "business_entity": True,
            },
        )

        edges_added: list[str] = []
        for target in connects_to:
            node = self._find_node(target)
            if node:
                graph.add_edge_simple(entity_id, node.id, relation="RELATES_TO")
                edges_added.append(node.id)

        auto_edges = 0
        if hasattr(graph, "auto_connect"):
            auto_edges = graph.auto_connect([entity_id])

        self._save_graph(graph)

        return json.dumps({
            "recorded": True,
            "mode": "entity",
            "entity_id": entity_id,
            "entity_type": entity_type.upper(),
            "description": description[:100],
            "connected_to": edges_added,
            "auto_edges": auto_edges,
            "total_nodes": len(graph.nodes),
        })

    async def _handle_learn_knowledge(self, args: dict[str, Any]) -> str:
        """Knowledge mode: teach domain facts that can't be extracted from code."""
        description = args.get("description", "")
        domain = args.get("domain", "general")
        tags = args.get("tags", [])

        if not description:
            return json.dumps({
                "error": "Knowledge mode requires 'description' (the fact to teach)."
            })

        graph = self._load_graph()
        if graph is None:
            return json.dumps({"error": "No graph loaded."})

        ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
        node_id = f"knowledge_{domain}_{ts}"

        graph.add_node_simple(
            node_id,
            label=description[:80],
            entity_type="KNOWLEDGE",
            description=description,
            properties={
                "source": "graq_learn_knowledge",
                "domain": domain,
                "tags": tags,
                "created": ts,
                "manual": True,
            },
        )

        auto_edges = 0
        if hasattr(graph, "auto_connect"):
            auto_edges = graph.auto_connect([node_id])

        self._save_graph(graph)

        return json.dumps({
            "recorded": True,
            "mode": "knowledge",
            "node_id": node_id,
            "domain": domain,
            "description": description[:100],
            "tags": tags,
            "auto_edges": auto_edges,
            "total_nodes": len(graph.nodes),
        })

    async def _handle_reload(self, args: dict[str, Any]) -> str:
        """Force-reload the knowledge graph, config, and backend from disk."""
        old_count = len(self._graph.nodes) if self._graph else 0
        self._graph = None  # Force reload
        self._config = None  # Force config re-read
        self._graph_mtime = 0.0
        graph = self._load_graph()
        new_count = len(graph.nodes) if graph else 0

        # Report backend status
        backend_info = "none"
        if graph:
            b = getattr(graph, "_default_backend", None)
            if b is not None:
                backend_info = type(b).__name__
            cfg_backend = getattr(getattr(self._config, "model", None), "backend", "unknown")
            backend_info = f"{cfg_backend} -> {backend_info}"

        return json.dumps({
            "status": "reloaded",
            "previous_nodes": old_count,
            "current_nodes": new_count,
            "graph_file": self._graph_file,
            "backend": backend_info,
        })

    # ── 9. graq_audit ─────────────────────────────────────────────

    async def _handle_audit(self, args: dict[str, Any]) -> str:
        """Deep chunk health audit of the knowledge graph."""
        graph = self._load_graph()
        if graph is None:
            return json.dumps({"error": "No graph loaded."})

        fix = args.get("fix", False)
        verbose = args.get("verbose", False)

        # Reuse the audit logic from the CLI command
        from graqle.cli.commands.audit import _run_audit

        report = _run_audit(graph)

        # Apply fix if requested
        if fix and report["nodes_without_chunks"]:
            before = report["nodes_with_chunks"]
            graph.rebuild_chunks(force=False)
            report = _run_audit(graph)
            report["fix_applied"] = True
            report["nodes_gained_chunks"] = report["nodes_with_chunks"] - before

            # Persist if graph file is known
            if self._graph_file:
                try:
                    graph.to_json(self._graph_file)
                    report["saved_to"] = self._graph_file
                except Exception as exc:
                    report["save_error"] = str(exc)

        # Trim hollow_nodes detail unless verbose
        if not verbose and report.get("hollow_nodes"):
            report["hollow_nodes_sample"] = report["hollow_nodes"][:5]
            report["hollow_nodes_total"] = len(report["hollow_nodes"])
            del report["hollow_nodes"]

        return json.dumps(report, indent=2)

    # ── 10. graq_runtime ────────────────────────────────────────────

    async def _handle_runtime(self, args: dict[str, Any]) -> str:
        """Query live runtime observability data."""
        query = args.get("query", "")
        source = args.get("source", "auto")
        service = args.get("service")
        hours = min(max(args.get("hours", 6), 0.1), 168)  # 1 week max
        severity_filter = args.get("severity_filter", "high")
        ingest = args.get("ingest", False)

        try:
            from graqle.runtime.detector import detect_environment
            from graqle.runtime.fetcher import create_fetcher
            from graqle.runtime.kg_builder import RuntimeKGBuilder

            # Detect environment
            env = detect_environment()

            # Resolve source
            provider = source if source != "auto" else env.provider

            # Get runtime config from graqle.yaml if available
            log_groups: list[str] = []
            log_paths: list[str] = []
            if self._config and hasattr(self._config, "runtime"):
                rt_cfg = self._config.runtime
                for src in rt_cfg.sources:
                    if src.log_group:
                        log_groups.append(src.log_group)
                    if src.log_path:
                        log_paths.append(src.log_path)

            # Infer service from query if not provided
            if not service and query:
                # Simple extraction: look for capitalized words or quoted strings
                import re
                quoted = re.findall(r'"([^"]+)"', query)
                if quoted:
                    service = quoted[0]

            # Create fetcher and fetch
            fetcher = create_fetcher(
                provider,
                region=env.region,
                log_groups=log_groups or None,
                log_paths=log_paths or None,
            )

            # Health check first
            health = fetcher.health_check()
            if health.get("status") == "error":
                return json.dumps({
                    "error": f"Runtime source unavailable: {health.get('error', 'unknown')}",
                    "provider": provider,
                    "environment": {
                        "detected": env.provider,
                        "confidence": env.confidence,
                        "region": env.region,
                    },
                    "hint": health.get("hint", "Check credentials and provider configuration."),
                })

            result = await fetcher.fetch(
                hours=hours,
                service=service,
                severity_filter=severity_filter,
                max_events=100,
            )

            # Build summary
            summary = RuntimeKGBuilder.summary(result)

            # Optionally ingest into KG
            ingest_result = None
            if ingest and result.events:
                graph_path = self._graph_file or "graqle.json"
                builder = RuntimeKGBuilder(graph_path=graph_path)
                ingest_result = builder.ingest_into_graph(result)
                # Force graph reload to pick up new nodes
                self._graph = None
                self._graph_mtime = 0.0

            response: dict[str, Any] = {
                "environment": {
                    "detected": env.provider,
                    "confidence": env.confidence,
                    "region": env.region,
                    "log_sources": env.log_sources,
                },
                "summary": summary,
                "events": [
                    {
                        "id": e.id,
                        "category": e.category,
                        "severity": e.severity,
                        "service": e.service_name,
                        "hits": e.hit_count,
                        "message": e.message[:300],
                        "timestamp": e.timestamp,
                    }
                    for e in result.events[:20]  # Return top 20 in response
                ],
                "fetch_duration_ms": round(result.fetch_duration_ms, 1),
            }

            if ingest_result:
                response["ingest"] = ingest_result
            if result.errors:
                response["errors"] = result.errors

            return json.dumps(response)

        except ImportError as exc:
            return json.dumps({
                "error": f"Runtime module dependency missing: {exc}",
                "hint": "pip install boto3 (for AWS) or azure-monitor-query (for Azure) or google-cloud-logging (for GCP)",
            })
        except Exception as exc:
            return json.dumps({"error": f"Runtime fetch failed: {exc}"})

    # ── 11. graq_route ──────────────────────────────────────────────

    async def _handle_route(self, args: dict[str, Any]) -> str:
        """Smart query router — recommend GraQle vs external tools."""
        question = args.get("question", "")

        if not question:
            return json.dumps({"error": "Parameter 'question' is required."})

        from graqle.runtime.router import route_question

        # Check if runtime data is available
        has_runtime = True  # graq_runtime is now built-in

        recommendation = route_question(question, has_runtime=has_runtime)

        return json.dumps(recommendation.to_dict())

    # ── 12. graq_lifecycle ────────────────────────────────────────────

    async def _handle_lifecycle(self, args: dict[str, Any]) -> str:
        """Session lifecycle hooks — context at key dev moments."""
        event = args.get("event", "")
        context_text = args.get("context", "")
        files = args.get("files", [])

        if not event:
            return json.dumps({"error": "Parameter 'event' is required."})

        graph = self._load_graph()
        response: dict[str, Any] = {
            "event": event,
            "graph_loaded": graph is not None,
        }

        if event == "session_start":
            # Return graph stats + backend status + recent lessons
            if graph is not None:
                stats = graph.stats
                response["graph"] = {
                    "nodes": stats.total_nodes,
                    "edges": stats.total_edges,
                    "components": stats.connected_components,
                    "hub_nodes": stats.hub_nodes[:5],
                }
            backend_status = self._check_backend_status(graph)
            response["backend"] = backend_status
            # Active branch info
            branch_info = self._read_active_branch()
            if branch_info:
                response["active_branch"] = branch_info
            # Recent lessons
            if graph is not None and context_text:
                lessons = self._find_lesson_nodes(context_text, severity_filter="high")
                if lessons:
                    response["relevant_lessons"] = lessons[:5]

        elif event == "investigation_start":
            # Return context nodes + lessons + route recommendation for the bug
            if context_text:
                if graph is not None:
                    matches = self._find_nodes_matching(context_text, limit=10)
                    if matches:
                        response["relevant_nodes"] = [
                            {"id": m.id, "label": m.label, "type": m.entity_type}
                            for m in matches
                        ]
                    lessons = self._find_lesson_nodes(context_text, severity_filter="all")
                    if lessons:
                        response["past_lessons"] = lessons[:5]
                # Route recommendation
                try:
                    from graqle.runtime.router import route_question
                    rec = route_question(context_text, has_runtime=True)
                    response["recommended_approach"] = rec.to_dict()
                except Exception:
                    pass

        elif event == "fix_complete":
            # Return impact analysis for changed files + preflight warnings
            if graph is not None and files:
                warnings: list[str] = []
                for fpath in files:
                    fname = Path(fpath).stem.lower()
                    for node in graph.nodes.values():
                        node_text = f"{node.id} {node.label}".lower()
                        if fname in node_text:
                            neighbors = self._get_neighbor_summaries(node.id)
                            if neighbors:
                                warnings.append(
                                    f"Changed '{fpath}' relates to '{node.label}' "
                                    f"({len(neighbors)} connections)"
                                )
                            break
                if warnings:
                    response["impact_warnings"] = warnings
            if context_text:
                response["suggestion"] = (
                    "Consider running graq_learn to record this fix outcome "
                    "so the graph remembers the pattern."
                )
        else:
            return json.dumps({"error": f"Unknown event type: {event}. Use: session_start, investigation_start, fix_complete"})

        return json.dumps(response)

    # ------------------------------------------------------------------
    # Shared helpers
    # ------------------------------------------------------------------

    def _check_backend_status(self, graph: Any) -> dict[str, Any]:
        """Check the status of the configured LLM backend.

        Returns a dict with 'status' ('ok', 'unavailable', 'not_configured')
        and diagnostic info.
        """
        status: dict[str, Any] = {"status": "unknown", "backend": "unknown"}

        if graph is None:
            status["status"] = "no_graph"
            return status

        # Check if graph has a real backend assigned
        backend = getattr(graph, "_default_backend", None)
        if backend is None:
            status["status"] = "not_configured"
            status["hint"] = "No LLM backend configured. Run 'graq doctor'."
            return status

        backend_name = getattr(backend, "name", str(type(backend).__name__))
        status["backend"] = backend_name

        # Detect mock/fallback backends
        if "mock" in backend_name.lower() or getattr(backend, "is_fallback", False):
            status["status"] = "unavailable"
            reason = getattr(backend, "fallback_reason", "Backend fell back to mock")
            status["error"] = reason
            status["hint"] = "LLM backend is not connected. Run 'graq doctor' to diagnose."
            return status

        status["status"] = "ok"
        return status

    def _find_lesson_nodes(
        self,
        text: str,
        *,
        severity_filter: str = "high",
    ) -> list[dict[str, Any]]:
        """Find LESSON / MISTAKE / SAFETY / ADR nodes matching text."""
        graph = self._load_graph()
        if graph is None:
            return []

        text_lower = text.lower()
        tokens = text_lower.split()

        severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
        filter_threshold = {
            "critical": 0,
            "high": 1,
            "all": 99,
        }.get(severity_filter, 1)

        results: list[dict[str, Any]] = []
        for node in graph.nodes.values():
            if node.entity_type.upper() not in _LESSON_ENTITY_TYPES:
                continue

            severity = node.properties.get("severity", "MEDIUM").upper()
            sev_rank = severity_order.get(severity, 2)
            if sev_rank > filter_threshold:
                continue

            haystack = f"{node.id} {node.label} {node.description[:300]}".lower()
            score = sum(1.0 for tok in tokens if tok in haystack)
            if score > 0 or severity_filter == "all":
                results.append({
                    "id": node.id,
                    "label": node.label,
                    "entity_type": node.entity_type,
                    "severity": severity,
                    "description": node.description[:200],
                    "hit_count": node.properties.get("hit_count", 0),
                    "score": score,
                })

        # Sort: highest severity first, then by relevance score
        results.sort(key=lambda x: (severity_order.get(x["severity"], 2), -x["score"]))
        matched = results[:_MAX_RESULTS]

        # Increment hit_count on each matched lesson node (BUG-7 fix)
        if matched and graph is not None:
            for result in matched:
                node = graph.nodes.get(result["id"])
                if node is not None:
                    current = node.properties.get("hit_count", 0)
                    node.properties["hit_count"] = current + 1
                    result["hit_count"] = current + 1
            self._save_graph(graph)

        return matched

    def _get_governance(self) -> Any:
        """Lazy-load governance middleware."""
        if self._gov is None:
            try:
                from graqle.intelligence.governance.middleware import GovernanceMiddleware
                self._gov = GovernanceMiddleware(Path("."))
            except Exception as exc:
                logger.debug("Governance middleware unavailable: %s", exc)
        return self._gov

    async def _handle_gate(self, args: dict[str, Any]) -> str:
        """Pre-compiled intelligence gate — instant module context (<100ms).

        Delegates to graqle.intelligence.gate.IntelligenceGate for clean separation.
        Automatically logged to governance audit trail.
        """
        import time as _time

        from graqle.intelligence.gate import IntelligenceGate

        t0 = _time.monotonic()
        gate = IntelligenceGate(Path("."))
        module_query = args.get("module", "")
        action = args.get("action", "context")

        if action == "scorecard":
            result = gate.get_scorecard()
        elif not module_query:
            result = {"error": "Parameter 'module' is required."}
        elif action == "impact":
            result = gate.get_impact(module_query)
        else:
            result = gate.get_context(module_query)

        duration_ms = (_time.monotonic() - t0) * 1000

        # Governance audit: log this tool call
        gov = self._get_governance()
        if gov is not None:
            try:
                session = gov.get_or_start_session(f"gate:{module_query or 'scorecard'}")
                gov.log_tool_call(session, "graq_gate", args, result, duration_ms=duration_ms)
            except Exception as exc:
                logger.debug("Governance logging failed: %s", exc)

        return json.dumps(result)

    async def _handle_gov_gate(self, args: dict[str, Any]) -> str:
        """Governance gate — run GovernanceMiddleware 3-tier check on a diff/file.

        This is the MCP-tool wrapper for GovernanceMiddleware.check(), used by:
        - WorkflowOrchestrator GATE stage
        - Direct MCP calls for pre-change governance validation
        - CI pipelines that cannot use the CLI

        Returns GateResult.to_dict() + 'blocked' field.
        Returns error GOVERNANCE_GATE if blocked=True (exit-code-1 equivalent).
        Writes GOVERNANCE_BYPASS KG node for every T2/T3 that passes.
        """
        from graqle.core.governance import GovernanceConfig, GovernanceMiddleware

        file_path = args.get("file_path", "")
        diff = args.get("diff", "")
        content = args.get("content", "")
        risk_level = str(args.get("risk_level", "LOW")).upper()
        impact_radius = int(args.get("impact_radius", 0))
        approved_by = str(args.get("approved_by", ""))
        justification = str(args.get("justification", ""))
        actor = str(args.get("actor", ""))

        if not file_path and not diff and not content:
            return json.dumps({"error": "Parameter 'file_path' is required."})

        cfg = GovernanceConfig()
        middleware = GovernanceMiddleware(cfg)
        gate = middleware.check(
            diff=diff,
            content=content,
            file_path=file_path,
            risk_level=risk_level,
            impact_radius=impact_radius,
            approved_by=approved_by,
            justification=justification,
            action="gov_gate",
            actor=actor,
        )

        result = gate.to_dict()

        if gate.blocked:
            result["error"] = "GOVERNANCE_GATE"
            return json.dumps(result)

        # Write GOVERNANCE_BYPASS KG node for T2/T3 that pass
        if gate.tier in ("T2", "T3"):
            try:
                from graqle.core.node import CogniNode
                bypass = middleware.build_bypass_node(
                    gate,
                    approved_by=approved_by,
                    justification=justification,
                    action="gov_gate",
                    actor=actor,
                )
                bypass_node = CogniNode(
                    id=bypass.bypass_id,
                    label=f"governance_bypass:{gate.tier}:{file_path}",
                    entity_type="GOVERNANCE_BYPASS",
                    properties=bypass.to_node_metadata(),
                )
                _g = self._load_graph()
                if _g is not None:
                    _g.add_node(bypass_node)
                    self._save_graph(_g)
            except Exception:
                pass  # Audit write MUST NEVER fail the primary operation

        return json.dumps(result)

    async def _handle_drace(self, args: dict[str, Any]) -> str:
        """DRACE governance scoring — query audit trail and session scores.

        Actions:
        - "score": Get DRACE score for a session
        - "sessions": List recent audit sessions
        - "trail": Get full audit trail for a session
        """
        gov = self._get_governance()
        if gov is None:
            return json.dumps({"error": "Governance middleware not available."})

        action = args.get("action", "sessions")
        session_id = args.get("session_id", "")

        if action == "sessions":
            from graqle.intelligence.governance.audit import AuditTrail
            trail = AuditTrail(Path("."))
            sessions = trail.list_sessions(limit=args.get("limit", 10))
            return json.dumps({"sessions": sessions, "count": len(sessions)})

        if action == "trail" and session_id:
            from graqle.intelligence.governance.audit import AuditTrail
            trail = AuditTrail(Path("."))
            session = trail.load_session(session_id)
            if session is None:
                return json.dumps({"error": f"Session '{session_id}' not found."})
            return json.dumps(session.model_dump(), default=str)

        if action == "score" and session_id:
            from graqle.intelligence.governance.audit import AuditTrail
            trail = AuditTrail(Path("."))
            session = trail.load_session(session_id)
            if session is None:
                return json.dumps({"error": f"Session '{session_id}' not found."})
            entries_data = [e.model_dump() for e in session.entries]
            score = gov._scorer.score_session(entries_data)
            score.session_id = session_id
            return json.dumps(score.to_dict())

        return json.dumps({"error": f"Unknown action: {action}. Use: sessions, trail, score."})

    # ── SCORCH plugin handlers ────────────────────────────────────────

    async def _handle_scorch_audit(self, args: dict[str, Any]) -> str:
        """Full SCORCH v3 audit pipeline."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({
                "error": "SCORCH plugin not available. "
                "Install with: pip install graqle[scorch] && python -m playwright install chromium"
            })

        config_path = args.get("config_path")
        if config_path:
            config = ScorchConfig.from_json(config_path)
        else:
            config = ScorchConfig(
                base_url=args.get("url", "http://localhost:3000"),
                pages=args.get("pages", ["/"]),
                skip_behavioral=args.get("skip_behavioral", False),
                skip_vision=args.get("skip_vision", False),
            )

        engine = ScorchEngine(config=config)
        report = await engine.run()

        # Auto-enrich KG if requested
        if args.get("enrich_kg", True) and self._graph_file:
            try:
                from graqle.plugins.scorch.kg_enrichment import enrich_graph
                graph_data = json.loads(
                    Path(self._graph_file).read_text(encoding="utf-8")
                )
                graph_data, added = enrich_graph(graph_data, report)
                if added > 0:
                    from graqle.core.graph import _write_with_lock
                    _write_with_lock(
                        str(self._graph_file),
                        json.dumps(graph_data, indent=2, default=str),
                    )
                    report["kg_nodes_added"] = added
            except Exception as exc:
                report["kg_enrichment_error"] = str(exc)

        return json.dumps(report, default=str)

    async def _handle_scorch_behavioral(self, args: dict[str, Any]) -> str:
        """Behavioral-only SCORCH run (Phase 2.5 — fast, no AI cost)."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({
                "error": "SCORCH plugin not available. "
                "Install with: pip install graqle[scorch]"
            })

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )

        engine = ScorchEngine(config=config)
        results = await engine.run_behavioral_only()
        return json.dumps(results, default=str)

    async def _handle_scorch_report(self, args: dict[str, Any]) -> str:
        """Read and summarize an existing SCORCH report."""
        report_path = args.get("report_path", "./scorch-output/report.json")

        try:
            with open(report_path, "r", encoding="utf-8") as f:
                report = json.load(f)
        except FileNotFoundError:
            return json.dumps({"error": f"No report found at {report_path}"})

        summary = {
            "pass": report.get("pass"),
            "journeyPass": report.get("journeyPass"),
            "severityCounts": report.get("severityCounts"),
            "behavioralSummary": report.get("behavioralSummary"),
            "journeyScore": report.get("journeyAnalysis", {}).get("journeyScore"),
            "strandedPoints": len(report.get("journeyAnalysis", {}).get("strandedPoints", [])),
            "flowBreaks": len(report.get("journeyAnalysis", {}).get("flowBreaks", [])),
            "issueCount": len(report.get("issues", [])),
            "summary": report.get("summary", ""),
        }
        return json.dumps(summary)

    # ── SCORCH Extended Skill Handlers ──

    async def _handle_scorch_a11y(self, args: dict[str, Any]) -> str:
        """WCAG 2.1 AA/AAA accessibility audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_a11y()
        return json.dumps(results, default=str)

    async def _handle_scorch_perf(self, args: dict[str, Any]) -> str:
        """Core Web Vitals performance audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_perf()
        return json.dumps(results, default=str)

    async def _handle_scorch_seo(self, args: dict[str, Any]) -> str:
        """SEO audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_seo()
        return json.dumps(results, default=str)

    async def _handle_scorch_mobile(self, args: dict[str, Any]) -> str:
        """Mobile-specific audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_mobile()
        return json.dumps(results, default=str)

    async def _handle_scorch_i18n(self, args: dict[str, Any]) -> str:
        """Internationalization audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_i18n()
        return json.dumps(results, default=str)

    async def _handle_scorch_security(self, args: dict[str, Any]) -> str:
        """Frontend security audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_security()
        return json.dumps(results, default=str)

    async def _handle_scorch_conversion(self, args: dict[str, Any]) -> str:
        """Conversion funnel analysis."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_conversion()
        return json.dumps(results, default=str)

    async def _handle_scorch_brand(self, args: dict[str, Any]) -> str:
        """Brand consistency audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_brand()
        return json.dumps(results, default=str)

    async def _handle_scorch_auth_flow(self, args: dict[str, Any]) -> str:
        """Authenticated user journey audit."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig(
            base_url=args.get("url", "http://localhost:3000"),
            pages=args.get("pages", ["/"]),
            auth_state=args.get("auth_state"),
        )
        engine = ScorchEngine(config=config)
        results = await engine.run_auth_flow()
        return json.dumps(results, default=str)

    async def _handle_scorch_diff(self, args: dict[str, Any]) -> str:
        """Before/after SCORCH report comparison."""
        try:
            from graqle.plugins.scorch import ScorchEngine, ScorchConfig
        except ImportError:
            return json.dumps({"error": "SCORCH plugin not available. Install with: pip install graqle[scorch]"})

        config = ScorchConfig()
        if args.get("current_report"):
            import os
            config.output_dir = os.path.dirname(args["current_report"]) or "./scorch-output"

        engine = ScorchEngine(config=config)
        results = await engine.run_diff(previous_report_path=args.get("previous_report"))
        return json.dumps(results, default=str)

    # ── Phantom plugin handlers ──────────────────────────────────────

    def _phantom_engine(self) -> Any:
        """Lazy-load PhantomEngine singleton."""
        if not hasattr(self, "_phantom"):
            self._phantom = None
        if self._phantom is None:
            try:
                from graqle.plugins.phantom import PhantomEngine, PhantomConfig
                self._phantom = PhantomEngine(PhantomConfig())
            except ImportError:
                raise ImportError(
                    "Phantom plugin not available. "
                    "Install with: pip install graqle[phantom] && python -m playwright install chromium"
                )
        return self._phantom

    async def _handle_phantom_browse(self, args: dict[str, Any]) -> str:
        """Open browser, navigate to URL, return screenshot + DOM summary."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.browse(args.pop("url"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_click(self, args: dict[str, Any]) -> str:
        """Click an element on the current page."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.click(args.pop("session_id"), args.pop("target"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_type(self, args: dict[str, Any]) -> str:
        """Type text into a form field."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.type_text(args.pop("session_id"), args.pop("target"), args.pop("text"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_screenshot(self, args: dict[str, Any]) -> str:
        """Take screenshot with optional Vision analysis."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.screenshot(args.pop("session_id"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_audit(self, args: dict[str, Any]) -> str:
        """Run SCORCH audit dimensions on current page."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.audit(args.pop("session_id"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_flow(self, args: dict[str, Any]) -> str:
        """Execute a multi-step user journey."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.flow(args.pop("name"), args.pop("steps"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_discover(self, args: dict[str, Any]) -> str:
        """Auto-discover all navigable pages from a starting URL."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.discover(args.pop("url"), **args)
        return json.dumps(result, default=str)

    async def _handle_phantom_session(self, args: dict[str, Any]) -> str:
        """Manage browser sessions and auth profiles."""
        try:
            engine = self._phantom_engine()
        except ImportError as e:
            return json.dumps({"error": str(e)})
        result = await engine.session_action(args.pop("action"), **args)
        return json.dumps(result, default=str)

    # ── graq_edit (TEAM/ENTERPRISE) ──────────────────────────────────
    # v0.38.0 — governed atomic file edit
    # Phase 2 of feature-coding-assistant plan

    async def _handle_edit(self, args: dict[str, Any]) -> str:
        """Read a file, apply a unified diff, write back atomically.

        Args:
            file_path: target file to edit (required)
            description: natural-language change description (used to generate diff if diff not provided)
            diff: unified diff string to apply directly (optional — overrides description)
            dry_run: if True, validate + return preview without writing (default True)

        Flow:
            plan gate → preflight → generate diff (if no diff provided) →
            safety_check → apply_diff() → return ApplyResult + CodeGenerationResult merged

        Gate: team/enterprise plan only.
        """
        from graqle.core.file_writer import apply_diff
        from graqle.cloud.credentials import load_credentials

        file_path = args.get("file_path", "")
        description = args.get("description", "")
        provided_diff = args.get("diff", "")
        dry_run = bool(args.get("dry_run", True))  # DEFAULT TRUE — never write without explicit False

        if not file_path:
            return json.dumps({"error": "Parameter 'file_path' is required."})
        if not description and not provided_diff:
            return json.dumps({"error": "Either 'description' or 'diff' is required."})

        # Plan gate
        try:
            creds = load_credentials()
            if creds.plan not in ("team", "enterprise"):
                return json.dumps({
                    "error": "PLAN_GATE",
                    "message": (
                        "graq_edit is available on Team and Enterprise plans. "
                        "Upgrade at https://graqle.com/pricing"
                    ),
                    "current_plan": creds.plan,
                    "required_plan": "team",
                })
        except Exception:
            pass  # dev/local setup

        # Step 1: Preflight
        preflight_raw = json.loads(await self._handle_preflight({
            "action": description or f"apply diff to {file_path}",
            "files": [file_path],
        }))

        # Step 1b: Governance gate (3-tier — TS-BLOCK / T1 / T2 / T3)
        try:
            from graqle.core.governance import GovernanceMiddleware
            _gov = GovernanceMiddleware()
            _impact_radius = int(preflight_raw.get("impact_radius", 0))
            _risk_level = str(preflight_raw.get("risk_level", "LOW")).upper()
            _gate = _gov.check(
                diff=provided_diff,
                file_path=file_path,
                risk_level=_risk_level,
                impact_radius=_impact_radius,
                approved_by=str(args.get("approved_by", "")),
                justification=str(args.get("justification", "")),
                action="edit",
                actor=str(args.get("actor", "")),
            )
            if _gate.blocked:
                return json.dumps({
                    "error": "GOVERNANCE_GATE",
                    "tier": _gate.tier,
                    "message": _gate.reason,
                    "warnings": _gate.warnings,
                    "requires_approval": _gate.requires_approval,
                    "gate_score": _gate.gate_score,
                })
            # Write GOVERNANCE_BYPASS KG node for every T2/T3 that passes
            # T1 auto-pass is logged only (no bypass node — not a bypass)
            if _gate.tier in ("T2", "T3"):
                try:
                    from graqle.core.node import CogniNode
                    _bypass = _gov.build_bypass_node(
                        _gate,
                        approved_by=str(args.get("approved_by", "")),
                        justification=str(args.get("justification", "")),
                        action="edit",
                        actor=str(args.get("actor", "")),
                    )
                    _bypass_node = CogniNode(
                        id=_bypass.bypass_id,
                        label=f"governance_bypass:{_gate.tier}:{file_path}",
                        entity_type="GOVERNANCE_BYPASS",
                        properties=_bypass.to_node_metadata(),
                    )
                    _graph_for_audit = self._load_graph()
                    if _graph_for_audit is not None:
                        _graph_for_audit.add_node(_bypass_node)
                        self._save_graph(_graph_for_audit)
                except Exception:
                    pass  # Audit write MUST NEVER fail the primary operation
        except ImportError:
            pass  # governance module optional in stripped builds

        # Step 2: Get diff — either provided directly or generate it
        unified_diff = provided_diff
        generation_result: dict[str, Any] | None = None

        if not unified_diff and description:
            gen_raw = json.loads(await self._handle_generate({
                "description": description,
                "file_path": file_path,
                "max_rounds": int(args.get("max_rounds", 2)),
                "dry_run": True,  # generation is always dry_run — edit applies it
            }))
            if "error" in gen_raw:
                return json.dumps(gen_raw)  # propagate generation error
            generation_result = gen_raw
            patches = gen_raw.get("patches", [])
            if patches:
                unified_diff = patches[0].get("unified_diff", "")

        if not unified_diff:
            return json.dumps({
                "error": "No diff available — generation returned empty patch",
                "preflight": preflight_raw,
            })

        # Step 3: Safety check on the diff
        secret_patterns = ["password", "secret", "api_key", "token", "aws_access", "aws_secret"]
        diff_lower = unified_diff.lower()
        exposed = [p for p in secret_patterns if p in diff_lower]
        if exposed:
            return json.dumps({
                "error": "SAFETY_GATE",
                "message": f"Diff may expose secrets: {exposed}. Edit blocked.",
                "dry_run": True,
            })

        # Step 4: Apply diff
        from pathlib import Path as _Path
        apply_result = apply_diff(
            _Path(file_path),
            unified_diff,
            dry_run=dry_run,
            skip_syntax_check=bool(args.get("skip_syntax_check", False)),
        )

        result: dict[str, Any] = {
            **apply_result.to_dict(),
            "preflight_risk": preflight_raw.get("risk_level", "low"),
            "preflight_warnings": preflight_raw.get("warnings", [])[:3],
        }
        if generation_result:
            result["generation"] = {
                k: generation_result[k]
                for k in ("confidence", "rounds_completed", "active_nodes", "cost_usd", "latency_ms")
                if k in generation_result
            }

        return json.dumps(result)

    # ── graq_generate (TEAM/ENTERPRISE) ──────────────────────────────
    # v0.38.0 — governed code generation
    # Phase 1 of feature-coding-assistant plan

    async def _handle_generate(self, args: dict[str, Any]) -> str:
        """Generate a unified diff patch using graph context + LLM backend.

        Args:
            description: what to generate / change (required)
            file_path: target file (optional — graph infers if omitted)
            max_rounds: LLM reasoning rounds (default 2, max 5)
            dry_run: if True, return diff without applying (always True in Phase 1)

        Returns:
            JSON of CodeGenerationResult.to_dict()

        Gate: team/enterprise plan only.
        """
        import time as _time
        from graqle.core.generation import CodeGenerationResult, DiffPatch, GenerationRequest
        from graqle.cloud.credentials import load_credentials

        description = args.get("description", "")
        file_path = args.get("file_path", "")
        max_rounds = min(max(int(args.get("max_rounds", 2)), 1), 5)
        dry_run = bool(args.get("dry_run", True))  # Phase 1: always dry_run
        stream = bool(args.get("stream", False))  # T3.4: backend streaming support

        if not description:
            return json.dumps({"error": "Parameter 'description' is required."})

        # Plan gate — team/enterprise only
        try:
            creds = load_credentials()
            if creds.plan not in ("team", "enterprise"):
                return json.dumps({
                    "error": "PLAN_GATE",
                    "message": (
                        "graq_generate is available on Team and Enterprise plans. "
                        "Upgrade at https://graqle.com/pricing"
                    ),
                    "current_plan": creds.plan,
                    "required_plan": "team",
                })
        except Exception:
            pass  # If credentials fail, let through (dev/local setup)

        t0 = _time.monotonic()
        graph = self._require_graph()

        # Step 1: Preflight — surface risks before generating
        preflight_raw = json.loads(await self._handle_preflight({
            "action": description,
            "files": [file_path] if file_path else [],
        }))
        preflight_risk = preflight_raw.get("risk_level", "low")

        # Step 1b: Governance gate (3-tier — TS-BLOCK / T1 / T2 / T3)
        try:
            from graqle.core.governance import GovernanceMiddleware
            _gov = GovernanceMiddleware()
            _impact_radius = int(preflight_raw.get("impact_radius", 0))
            _risk_level = str(preflight_raw.get("risk_level", "LOW")).upper()
            _gate = _gov.check(
                content=description,
                file_path=file_path,
                risk_level=_risk_level,
                impact_radius=_impact_radius,
                approved_by=str(args.get("approved_by", "")),
                justification=str(args.get("justification", "")),
                action="generate",
                actor=str(args.get("actor", "")),
            )
            if _gate.blocked:
                return json.dumps({
                    "error": "GOVERNANCE_GATE",
                    "tier": _gate.tier,
                    "message": _gate.reason,
                    "warnings": _gate.warnings,
                    "requires_approval": _gate.requires_approval,
                    "gate_score": _gate.gate_score,
                })
            # Write GOVERNANCE_BYPASS KG node for every T2/T3 that passes
            if _gate.tier in ("T2", "T3"):
                try:
                    from graqle.core.node import CogniNode
                    _bypass = _gov.build_bypass_node(
                        _gate,
                        approved_by=str(args.get("approved_by", "")),
                        justification=str(args.get("justification", "")),
                        action="generate",
                        actor=str(args.get("actor", "")),
                    )
                    _bypass_node = CogniNode(
                        id=_bypass.bypass_id,
                        label=f"governance_bypass:{_gate.tier}:{file_path}",
                        entity_type="GOVERNANCE_BYPASS",
                        properties=_bypass.to_node_metadata(),
                    )
                    _graph_for_audit = self._load_graph()
                    if _graph_for_audit is not None:
                        _graph_for_audit.add_node(_bypass_node)
                        self._save_graph(_graph_for_audit)
                except Exception:
                    pass  # Audit write MUST NEVER fail the primary operation
        except ImportError:
            pass  # governance module optional in stripped builds

        # Step 2: Build generation prompt and call the LLM via graph.areason()
        file_context = f" for file '{file_path}'" if file_path else ""
        generation_prompt = (
            f"CODE GENERATION TASK{file_context}:\n"
            f"{description}\n\n"
            f"Instructions:\n"
            f"1. Produce ONLY a valid unified diff in standard format (--- a/... +++ b/... @@ hunks)\n"
            f"2. Keep changes minimal and focused on the described task\n"
            f"3. Preserve existing code style and conventions\n"
            f"4. Never include secrets, credentials, or internal threshold values\n"
            f"5. After the diff, add one line: SUMMARY: <one sentence>\n"
        )

        stream_chunks: list[str] = []
        try:
            if stream:
                # T3.4: streaming path — collect chunks from agenerate_stream().
                # Works with ANY backend:
                #   Anthropic → native token-by-token via client.messages.stream()
                #   All others → single-chunk fallback from BaseBackend.agenerate_stream()
                # The full result is also returned in the standard `answer` field so
                # callers that ignore `chunks` still get the complete response.
                async for chunk in graph.areason_stream(
                    generation_prompt,
                    max_rounds=max_rounds,
                ):
                    if hasattr(chunk, "content"):
                        stream_chunks.append(chunk.content)
                    else:
                        stream_chunks.append(str(chunk))
                # Re-run non-streaming for the structured ReasoningResult fields
                # (confidence, active_nodes, cost_usd, etc.) — areason_stream only yields text.
                result = await graph.areason(
                    generation_prompt,
                    max_rounds=max_rounds,
                    task_type="reason",
                )
            else:
                result = await graph.areason(
                    generation_prompt,
                    max_rounds=max_rounds,
                    task_type="reason",
                )
        except Exception as exc:
            err = str(exc)[:300]
            return json.dumps({
                "error": "GENERATION_BACKEND_UNAVAILABLE",
                "message": f"graq_generate requires a working LLM backend. Error: {err}",
                "fix": "Run 'graq doctor' to diagnose. Check graqle.yaml model.backend.",
                "confidence": 0.0,
            })

        latency_ms = (_time.monotonic() - t0) * 1000

        # Step 3: Parse the LLM answer into a DiffPatch
        raw_answer = result.answer or ""
        summary_line = ""
        diff_text = raw_answer

        # Extract SUMMARY: line if present
        lines = raw_answer.splitlines()
        diff_lines = []
        for line in lines:
            if line.startswith("SUMMARY:"):
                summary_line = line[len("SUMMARY:"):].strip()
            else:
                diff_lines.append(line)
        diff_text = "\n".join(diff_lines).strip()

        # Count added/removed lines
        lines_added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
        lines_removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))

        # Build preview (first ~5 non-header diff lines)
        preview_lines = [l for l in diff_lines if l.startswith(("@@", "+", "-")) and not l.startswith(("+++", "---"))]
        preview = "\n".join(preview_lines[:5])

        patches: list[DiffPatch] = []
        if diff_text:
            patches.append(DiffPatch(
                file_path=file_path or "(inferred from graph)",
                unified_diff=diff_text,
                lines_added=lines_added,
                lines_removed=lines_removed,
                preview=preview,
            ))

        # Step 4: Safety check on output (fire-and-forget, non-blocking)
        safety_warnings: list[str] = []
        try:
            safety_raw = json.loads(await self._handle_safety_check({
                "component": file_path or description[:50],
                "change_type": "modify",
                "skip_reasoning": True,
            }))
            if safety_raw.get("overall_risk") in ("high",):
                safety_warnings.append(
                    f"Safety check flagged HIGH risk: {safety_raw.get('preflight', {}).get('risk_level')}"
                )
        except Exception:
            pass  # safety_check is advisory — never block generation

        # Step 5: Assemble CodeGenerationResult
        generation_result = CodeGenerationResult(
            query=description,
            answer=summary_line or f"Generated diff: {lines_added} lines added, {lines_removed} removed.",
            confidence=round(result.confidence, 3),
            rounds_completed=result.rounds_completed,
            active_nodes=result.active_nodes[:10],
            cost_usd=round(result.cost_usd, 6),
            latency_ms=round(latency_ms, 1),
            patches=patches,
            files_affected=[file_path] if file_path else [],
            dry_run=dry_run,
            backend_status=result.backend_status,
            backend_error=result.backend_error,
            metadata={
                "preflight_risk": preflight_risk,
                "safety_warnings": safety_warnings,
                "preflight_warnings": preflight_raw.get("warnings", [])[:3],
                "stream": stream,
                "chunks": stream_chunks,  # empty list when stream=False
            },
        )

        return json.dumps(generation_result.to_dict())

    # ── Phase 3.5: File system tools ──────────────────────────────────────
    # graq_read, graq_write, graq_grep, graq_glob, graq_bash
    # graq_git_status, graq_git_diff, graq_git_log, graq_git_commit, graq_git_branch

    async def _handle_read(self, args: dict[str, Any]) -> str:
        """Read a file with optional line range."""
        import linecache

        file_path = args.get("file_path", "")
        if not file_path:
            return json.dumps({"error": "Parameter 'file_path' is required."})

        offset = max(1, int(args.get("offset", 1)))
        limit = min(max(1, int(args.get("limit", 200))), 2000)

        fp = Path(file_path)
        if not fp.exists():
            return json.dumps({"error": f"File not found: {file_path}", "exists": False})
        if fp.is_dir():
            return json.dumps({"error": f"Path is a directory: {file_path}"})

        try:
            lines = fp.read_text(encoding="utf-8", errors="replace").splitlines()
            total_lines = len(lines)
            selected = lines[offset - 1: offset - 1 + limit]
            numbered = "\n".join(f"{offset + i:>6}\t{line}" for i, line in enumerate(selected))
            return json.dumps({
                "file_path": str(fp),
                "content": numbered,
                "lines_returned": len(selected),
                "total_lines": total_lines,
                "offset": offset,
                "limit": limit,
                "truncated": (offset - 1 + limit) < total_lines,
            })
        except Exception as exc:
            return json.dumps({"error": f"Read failed: {exc}"})

    async def _handle_write(self, args: dict[str, Any]) -> str:
        """Atomically write a file. Patent scan first. dry_run=True by default."""
        import tempfile
        import os as _os

        file_path = args.get("file_path", "")
        content = args.get("content", "")
        dry_run = bool(args.get("dry_run", True))

        if not file_path:
            return json.dumps({"error": "Parameter 'file_path' is required."})
        if content is None:
            return json.dumps({"error": "Parameter 'content' is required."})

        # Patent scan — block if trade secrets detected in content
        _TS_PATTERNS = [
            "w_J", "w_A", "0.16", "theta_fold", "jaccard.*formula",
            "70.*30.*blend", "AGREEMENT_THRESHOLD",
        ]
        import re
        for pat in _TS_PATTERNS:
            if re.search(pat, content):
                return json.dumps({
                    "error": "PATENT_GATE",
                    "message": f"Content matches trade secret pattern '{pat}'. Write blocked.",
                })

        fp = Path(file_path)
        if dry_run:
            return json.dumps({
                "file_path": str(fp),
                "dry_run": True,
                "lines": len(content.splitlines()),
                "bytes": len(content.encode()),
                "message": "dry_run=True — pass dry_run=False to write.",
            })

        try:
            fp.parent.mkdir(parents=True, exist_ok=True)
            dir_ = fp.parent
            with tempfile.NamedTemporaryFile(
                mode="w", dir=dir_, suffix=".tmp", delete=False, encoding="utf-8"
            ) as tmp:
                tmp.write(content)
                tmp.flush()
                _os.fsync(tmp.fileno())
                tmp_path = tmp.name
            _os.replace(tmp_path, fp)
            return json.dumps({
                "file_path": str(fp),
                "written": True,
                "lines": len(content.splitlines()),
                "bytes": len(content.encode()),
            })
        except Exception as exc:
            return json.dumps({"error": f"Write failed: {exc}", "written": False})

    async def _handle_grep(self, args: dict[str, Any]) -> str:
        """Search file contents by regex pattern."""
        import re
        import fnmatch

        pattern = args.get("pattern", "")
        if not pattern:
            return json.dumps({"error": "Parameter 'pattern' is required."})

        search_path = Path(args.get("path", "."))
        glob_filter = args.get("glob", "")
        case_insensitive = bool(args.get("case_insensitive", False))
        context_lines = min(int(args.get("context_lines", 0)), 5)
        max_results = min(int(args.get("max_results", 50)), 500)

        flags = re.IGNORECASE if case_insensitive else 0
        try:
            compiled = re.compile(pattern, flags)
        except re.error as exc:
            return json.dumps({"error": f"Invalid regex: {exc}"})

        matches = []
        try:
            if search_path.is_file():
                files_to_search = [search_path]
            else:
                if glob_filter:
                    files_to_search = list(search_path.rglob(glob_filter))
                else:
                    files_to_search = [
                        p for p in search_path.rglob("*")
                        if p.is_file() and not any(
                            part.startswith(".") for part in p.parts
                        )
                    ]

            for fp in sorted(files_to_search)[:1000]:
                if len(matches) >= max_results:
                    break
                try:
                    file_lines = fp.read_text(encoding="utf-8", errors="replace").splitlines()
                except Exception:
                    continue
                for i, line in enumerate(file_lines):
                    if compiled.search(line):
                        ctx_before = file_lines[max(0, i - context_lines):i]
                        ctx_after = file_lines[i + 1:i + 1 + context_lines]
                        matches.append({
                            "file": str(fp),
                            "line_number": i + 1,
                            "line": line,
                            "context_before": ctx_before,
                            "context_after": ctx_after,
                        })
                        if len(matches) >= max_results:
                            break
        except Exception as exc:
            return json.dumps({"error": f"Grep failed: {exc}"})

        return json.dumps({
            "pattern": pattern,
            "matches": matches,
            "total_matches": len(matches),
            "truncated": len(matches) >= max_results,
        })

    async def _handle_glob(self, args: dict[str, Any]) -> str:
        """Find files by glob pattern."""
        pattern = args.get("pattern", "")
        if not pattern:
            return json.dumps({"error": "Parameter 'pattern' is required."})

        base_path = Path(args.get("path", "."))
        max_results = min(int(args.get("max_results", 100)), 1000)

        try:
            # Sort by modification time (newest first)
            matches = sorted(
                base_path.rglob(pattern) if "**" in pattern else base_path.glob(pattern),
                key=lambda p: p.stat().st_mtime,
                reverse=True,
            )
            result_paths = [str(p) for p in matches if p.is_file()][:max_results]
            return json.dumps({
                "pattern": pattern,
                "files": result_paths,
                "total": len(result_paths),
                "truncated": len(result_paths) >= max_results,
            })
        except Exception as exc:
            return json.dumps({"error": f"Glob failed: {exc}"})

    async def _handle_bash(self, args: dict[str, Any]) -> str:
        """Execute a governed shell command."""
        import subprocess
        import shlex

        command = args.get("command", "").strip()
        if not command:
            return json.dumps({"error": "Parameter 'command' is required."})

        dry_run = bool(args.get("dry_run", False))
        timeout = min(max(1, int(args.get("timeout", 30))), 120)
        cwd = args.get("cwd") or "."

        # Safety blocklist — destructive commands
        _BLOCKED = [
            "rm -rf", "git push --force", "git push -f",
            "DROP TABLE", "DROP DATABASE", "format c:",
            ":(){:|:&};:",  # fork bomb
        ]
        for blocked in _BLOCKED:
            if blocked.lower() in command.lower():
                return json.dumps({
                    "error": "BLOCKED_COMMAND",
                    "message": f"Command contains blocked pattern: '{blocked}'",
                    "command": command,
                })

        if dry_run:
            return json.dumps({"command": command, "dry_run": True, "message": "dry_run=True — pass dry_run=False to execute."})

        try:
            result = subprocess.run(
                command,
                shell=True,
                capture_output=True,
                text=True,
                timeout=timeout,
                cwd=cwd,
            )
            return json.dumps({
                "command": command,
                "stdout": result.stdout[:4000],
                "stderr": result.stderr[:1000],
                "exit_code": result.returncode,
                "success": result.returncode == 0,
                "truncated": len(result.stdout) > 4000,
            })
        except subprocess.TimeoutExpired:
            return json.dumps({"error": f"Command timed out after {timeout}s", "command": command})
        except Exception as exc:
            return json.dumps({"error": f"Bash failed: {exc}", "command": command})

    # ── Phase 3.5: Git tools ─────────────────────────────────────────────

    async def _handle_git_status(self, args: dict[str, Any]) -> str:
        """git status — show changed/staged/untracked files."""
        cwd = args.get("cwd", ".")
        return await self._handle_bash({"command": "git status --porcelain", "cwd": cwd, "dry_run": False, "timeout": 10})

    async def _handle_git_diff(self, args: dict[str, Any]) -> str:
        """git diff — staged or unstaged."""
        staged = bool(args.get("staged", False))
        base_ref = args.get("base_ref", "")
        file_path = args.get("file_path", "")
        cwd = args.get("cwd", ".")

        if base_ref:
            cmd = f"git diff {base_ref}...HEAD"
        elif staged:
            cmd = "git diff --cached"
        else:
            cmd = "git diff"

        if file_path:
            cmd += f" -- {file_path}"

        return await self._handle_bash({"command": cmd, "cwd": cwd, "dry_run": False, "timeout": 15})

    async def _handle_git_log(self, args: dict[str, Any]) -> str:
        """git log — recent commits."""
        n = min(max(1, int(args.get("n", 10))), 50)
        file_path = args.get("file_path", "")
        cwd = args.get("cwd", ".")

        cmd = f"git log --oneline -n {n}"
        if file_path:
            cmd += f" -- {file_path}"

        return await self._handle_bash({"command": cmd, "cwd": cwd, "dry_run": False, "timeout": 10})

    async def _handle_git_commit(self, args: dict[str, Any]) -> str:
        """Governed git commit — patent scan on staged changes first."""
        message = args.get("message", "").strip()
        if not message:
            return json.dumps({"error": "Parameter 'message' is required."})

        files = args.get("files", [])
        dry_run = bool(args.get("dry_run", True))
        cwd = args.get("cwd", ".")

        # Patent scan on staged diff
        diff_raw = json.loads(await self._handle_git_diff({"staged": True, "cwd": cwd}))
        diff_text = diff_raw.get("stdout", "")
        import re
        _TS_PATTERNS = ["w_J", "w_A", r"\b0\.16\b", "theta_fold", "AGREEMENT_THRESHOLD"]
        for pat in _TS_PATTERNS:
            if re.search(pat, diff_text):
                return json.dumps({
                    "error": "PATENT_GATE",
                    "message": f"Staged diff matches trade secret pattern '{pat}'. Commit blocked.",
                })

        if dry_run:
            return json.dumps({"message": message, "dry_run": True, "message_out": "dry_run=True — pass dry_run=False to commit."})

        # Stage files if specified
        if files:
            stage_cmd = "git add " + " ".join(f'"{f}"' for f in files)
            await self._handle_bash({"command": stage_cmd, "cwd": cwd, "dry_run": False, "timeout": 10})

        escaped = message.replace('"', '\\"')
        return await self._handle_bash({
            "command": f'git commit -m "{escaped}"',
            "cwd": cwd,
            "dry_run": False,
            "timeout": 30,
        })

    async def _handle_git_branch(self, args: dict[str, Any]) -> str:
        """Create or switch git branches."""
        name = args.get("name", "").strip()
        action = args.get("action", "create_and_switch")
        cwd = args.get("cwd", ".")

        if not name and action != "list":
            return json.dumps({"error": "Parameter 'name' is required."})

        cmd_map = {
            "create": f"git branch {name}",
            "switch": f"git checkout {name}",
            "create_and_switch": f"git checkout -b {name}",
            "list": "git branch -a",
        }
        cmd = cmd_map.get(action, f"git checkout -b {name}")
        return await self._handle_bash({"command": cmd, "cwd": cwd, "dry_run": False, "timeout": 15})

    # ── v0.38.0 Phase 4: Compound workflow handlers ─────────────────────────

    async def _handle_review(self, args: dict[str, Any]) -> str:
        """Structured code review using knowledge graph context."""
        file_path = args.get("file_path", "").strip()
        diff = args.get("diff", "").strip()
        focus = args.get("focus", "all")
        context_depth = int(args.get("context_depth", 1))

        if not file_path and not diff:
            return json.dumps({"error": "Provide either 'file_path' or 'diff' to review."})

        # Gather content
        content = diff
        if not content and file_path:
            read_result = json.loads(await self._handle_read({"file_path": file_path}))
            if "error" in read_result:
                return json.dumps(read_result)
            content = read_result.get("content", "")

        # Build focused review prompt
        focus_instructions = {
            "security": "Focus ONLY on OWASP Top 10 vulnerabilities, secret exposure, unsafe subprocess calls.",
            "correctness": "Focus ONLY on logic errors, null pointer risks, incorrect assumptions.",
            "style": "Focus ONLY on naming conventions, code style, readability.",
            "complexity": "Focus ONLY on cyclomatic complexity, deeply nested conditionals, long functions.",
            "tests": "Focus ONLY on test coverage gaps — what is not tested.",
            "all": "Review all dimensions: security, correctness, style, complexity, test coverage.",
        }
        focus_text = focus_instructions.get(focus, focus_instructions["all"])

        graph = self._load_graph()
        graph_context = ""
        if graph and file_path:
            try:
                ctx_result = json.loads(await self.handle_tool("graq_context", {
                    "module": file_path,
                    "depth": context_depth,
                }))
                graph_context = f"\n\nGraph context:\n{json.dumps(ctx_result, indent=2)}"
            except Exception:
                pass

        review_prompt = (
            f"Perform a code review. {focus_text}\n\n"
            f"Code to review:\n```\n{content}\n```"
            f"{graph_context}\n\n"
            "Output a JSON object with:\n"
            "- 'summary': one-line summary\n"
            "- 'verdict': APPROVED | CHANGES_REQUESTED | BLOCKED\n"
            "- 'comments': list of {severity, file_path, line_range, description, suggestion}\n"
            "Severity values: BLOCKER, MAJOR, MINOR, INFO"
        )

        try:
            graph_obj = self._load_graph()
            if graph_obj is None:
                return json.dumps({"error": "No graph loaded — cannot run review."})
            result = await graph_obj.areason(review_prompt, max_rounds=2, task_type="code")
            return json.dumps({
                "tool": "graq_review",
                "focus": focus,
                "file_path": file_path or "(diff)",
                "review": result.answer if hasattr(result, "answer") else str(result),
            })
        except Exception as exc:
            return json.dumps({"error": str(exc), "tool": "graq_review"})

    async def _handle_debug(self, args: dict[str, Any]) -> str:
        """Diagnose a bug from error/stack trace using graph call context."""
        error = args.get("error", "").strip()
        symptom = args.get("symptom", "").strip()
        file_path = args.get("file_path", "").strip()
        include_fix = bool(args.get("include_fix", True))

        signal = error or symptom
        if not signal:
            return json.dumps({"error": "Provide either 'error' (stack trace) or 'symptom' description."})

        # Optionally gather file content for context
        file_context = ""
        if file_path:
            read_result = json.loads(await self._handle_read({"file_path": file_path}))
            if "content" in read_result:
                lines = read_result["content"].split("\n")
                # Limit to 100 lines to keep context manageable
                file_context = "\n".join(lines[:100])
                if len(lines) > 100:
                    file_context += f"\n... ({len(lines) - 100} more lines)"

        fix_instruction = (
            "\n- 'proposed_fix': unified diff fixing the root cause (or empty string if unknown)"
            if include_fix else ""
        )

        debug_prompt = (
            f"Debug this issue using the knowledge graph call context.\n\n"
            f"Error/Symptom:\n{signal}\n"
            + (f"\nFile context ({file_path}):\n```\n{file_context}\n```\n" if file_context else "")
            + "\nOutput a JSON object with:\n"
            "- 'root_cause': concise root cause description\n"
            "- 'affected_files': list of file paths likely involved\n"
            "- 'confidence': HIGH | MEDIUM | LOW\n"
            f"- 'test_to_add': pytest test that would catch this bug{fix_instruction}"
        )

        try:
            graph_obj = self._load_graph()
            if graph_obj is None:
                return json.dumps({"error": "No graph loaded — cannot run debug analysis."})
            result = await graph_obj.areason(debug_prompt, max_rounds=2, task_type="reason")
            return json.dumps({
                "tool": "graq_debug",
                "signal": signal[:200],
                "file_path": file_path or None,
                "analysis": result.answer if hasattr(result, "answer") else str(result),
            })
        except Exception as exc:
            return json.dumps({"error": str(exc), "tool": "graq_debug"})

    async def _handle_scaffold(self, args: dict[str, Any]) -> str:
        """Scaffold new modules/classes/APIs/tests from a spec, matching project conventions."""
        spec = args.get("spec", "").strip()
        scaffold_type = args.get("scaffold_type", "module")
        output_dir = args.get("output_dir", ".").strip()
        dry_run = bool(args.get("dry_run", True))
        with_tests = bool(args.get("with_tests", True))

        if not spec:
            return json.dumps({"error": "Parameter 'spec' is required."})

        # Gather patterns from graph for convention matching
        graph_context = ""
        try:
            pattern_result = json.loads(await self.handle_tool("graq_context", {
                "module": scaffold_type,
                "depth": 1,
            }))
            graph_context = f"\nExisting project patterns:\n{json.dumps(pattern_result, indent=2)}"
        except Exception:
            pass

        test_instruction = (
            "\n\nAlso generate a corresponding pytest test file."
            if with_tests else ""
        )

        scaffold_prompt = (
            f"Scaffold a new {scaffold_type} for: {spec}\n"
            f"Target directory: {output_dir}\n"
            f"{graph_context}"
            f"{test_instruction}\n\n"
            "Output a JSON object with:\n"
            "- 'files': list of {file_path, content} objects to create\n"
            "- 'description': what was scaffolded\n"
            "- 'next_steps': list of manual steps needed after scaffolding\n"
            "Match existing project naming conventions exactly."
        )

        try:
            graph_obj = self._load_graph()
            if graph_obj is None:
                return json.dumps({"error": "No graph loaded — cannot run scaffold."})
            result = await graph_obj.areason(scaffold_prompt, max_rounds=2, task_type="generate")
            scaffold_data = result.answer if hasattr(result, "answer") else str(result)

            if dry_run:
                return json.dumps({
                    "tool": "graq_scaffold",
                    "dry_run": True,
                    "spec": spec,
                    "scaffold_type": scaffold_type,
                    "output_dir": output_dir,
                    "scaffold": scaffold_data,
                    "message": "dry_run=True — review scaffold output, then pass dry_run=False to write files.",
                })

            # Non-dry-run: write each file
            written: list[str] = []
            import json as _json
            try:
                scaffold_obj = _json.loads(scaffold_data) if isinstance(scaffold_data, str) else scaffold_data
                files = scaffold_obj.get("files", []) if isinstance(scaffold_obj, dict) else []
            except Exception:
                files = []

            for f in files:
                fp = f.get("file_path", "")
                content = f.get("content", "")
                if fp and content:
                    write_result = json.loads(await self._handle_write({
                        "file_path": fp,
                        "content": content,
                        "dry_run": False,
                    }))
                    if "error" not in write_result:
                        written.append(fp)

            return json.dumps({
                "tool": "graq_scaffold",
                "dry_run": False,
                "written": written,
                "scaffold": scaffold_data,
            })

        except Exception as exc:
            return json.dumps({"error": str(exc), "tool": "graq_scaffold"})

    async def _handle_workflow(self, args: dict[str, Any]) -> str:
        """Orchestrate multi-step coding workflows (Phase 9: governed state machine)."""
        workflow = args.get("workflow", "").strip()
        goal = args.get("goal", "").strip()
        context = args.get("context", {})
        dry_run = bool(args.get("dry_run", True))
        max_steps = int(args.get("max_steps", 10))

        # Phase 9: governed workflow types route through WorkflowOrchestrator
        _governed_types = {"governed_edit", "governed_generate", "governed_refactor"}
        if workflow in _governed_types and goal:
            try:
                from graqle.core.workflow_orchestrator import WorkflowOrchestrator
                _policy = getattr(self._config, "governance", None) if self._config else None

                orch = WorkflowOrchestrator(policy=_policy)
                plan = orch.build_plan(
                    goal,
                    files=context.get("files") or args.get("files") or [],
                    workflow_type=workflow,
                    actor=str(args.get("actor", "")),
                    approved_by=str(args.get("approved_by", "")),
                    justification=str(args.get("justification", "")),
                    skip_stages=args.get("skip_stages") or [],
                    dry_run=dry_run,
                )
                result = await orch.execute(plan, self.handle_tool)
                r = result.to_dict()
                r["tool"] = "graq_workflow"
                return json.dumps(r)
            except ImportError:
                pass  # orchestrator module optional — fall through to legacy path

        if not workflow:
            return json.dumps({"error": "Parameter 'workflow' is required."})
        if not goal:
            return json.dumps({"error": "Parameter 'goal' is required."})

        # Workflow definitions: ordered step plans with tool routing
        workflow_plans: dict[str, list[dict]] = {
            "bug_fix": [
                {"step": 1, "tool": "graq_grep", "description": "Locate error in codebase"},
                {"step": 2, "tool": "graq_read", "description": "Read affected file"},
                {"step": 3, "tool": "graq_debug", "description": "Diagnose root cause"},
                {"step": 4, "tool": "graq_generate", "description": "Generate fix as diff"},
                {"step": 5, "tool": "graq_write", "description": "Apply fix (dry_run=True by default)"},
                {"step": 6, "tool": "graq_bash", "description": "Run tests to verify fix"},
                {"step": 7, "tool": "graq_git_commit", "description": "Commit fix (dry_run=True by default)"},
                {"step": 8, "tool": "graq_learn", "description": "Record lesson in knowledge graph"},
            ],
            "scaffold_and_test": [
                {"step": 1, "tool": "graq_context", "description": "Gather project conventions"},
                {"step": 2, "tool": "graq_scaffold", "description": "Scaffold new component"},
                {"step": 3, "tool": "graq_write", "description": "Write scaffolded files (dry_run=True)"},
                {"step": 4, "tool": "graq_bash", "description": "Run tests to validate scaffold"},
                {"step": 5, "tool": "graq_learn", "description": "Record scaffold pattern"},
            ],
            "governed_refactor": [
                {"step": 1, "tool": "graq_git_branch", "description": "Create feature branch"},
                {"step": 2, "tool": "graq_impact", "description": "Analyse impact radius"},
                {"step": 3, "tool": "graq_preflight", "description": "Pre-change safety check"},
                {"step": 4, "tool": "graq_generate", "description": "Generate refactor diff"},
                {"step": 5, "tool": "graq_write", "description": "Apply refactor (dry_run=True)"},
                {"step": 6, "tool": "graq_bash", "description": "Run full test suite"},
                {"step": 7, "tool": "graq_gate", "description": "Gate check on quality"},
                {"step": 8, "tool": "graq_git_commit", "description": "Commit refactor (dry_run=True)"},
            ],
            "review_and_fix": [
                {"step": 1, "tool": "graq_read", "description": "Read file to review"},
                {"step": 2, "tool": "graq_review", "description": "Perform code review"},
                {"step": 3, "tool": "graq_generate", "description": "Generate fixes for BLOCKER/MAJOR issues"},
                {"step": 4, "tool": "graq_write", "description": "Apply fixes (dry_run=True)"},
                {"step": 5, "tool": "graq_bash", "description": "Run tests after fix"},
            ],
        }

        plan = workflow_plans.get(workflow)
        if plan is None:
            return json.dumps({
                "error": f"Unknown workflow '{workflow}'. Available: {list(workflow_plans.keys())}",
            })

        # Cap steps at max_steps
        plan = plan[:max_steps]

        if dry_run:
            return json.dumps({
                "tool": "graq_workflow",
                "workflow": workflow,
                "goal": goal,
                "dry_run": True,
                "steps": plan,
                "context": context,
                "message": (
                    f"Workflow '{workflow}' plan: {len(plan)} steps. "
                    "Pass dry_run=False to execute. "
                    "Each write step will still use dry_run=True unless overridden in context."
                ),
            })

        # Non-dry-run: execute steps sequentially
        results: list[dict] = []
        for step in plan:
            step_tool = step["tool"]
            step_args = dict(context)
            step_args["dry_run"] = True  # Inner steps default to dry_run=True for safety

            try:
                step_result = json.loads(await self.handle_tool(step_tool, step_args))
                results.append({
                    "step": step["step"],
                    "tool": step_tool,
                    "description": step["description"],
                    "status": "ok" if "error" not in step_result else "error",
                    "result": step_result,
                })
                # Stop on unrecoverable error
                if "error" in step_result and step_result.get("error"):
                    results.append({"step": "STOPPED", "reason": step_result["error"]})
                    break
            except Exception as exc:
                results.append({
                    "step": step["step"],
                    "tool": step_tool,
                    "status": "exception",
                    "error": str(exc),
                })
                break

        return json.dumps({
            "tool": "graq_workflow",
            "workflow": workflow,
            "goal": goal,
            "steps_executed": len(results),
            "results": results,
        })

    async def _handle_test(self, args: dict[str, Any]) -> str:
        """Run pytest and parse structured CodeMetric output."""
        import re
        import subprocess

        target = args.get("target", "tests/").strip() or "tests/"
        coverage = bool(args.get("coverage", False))
        fail_fast = bool(args.get("fail_fast", False))
        cwd = args.get("cwd", ".").strip() or "."
        record_metrics = bool(args.get("record_metrics", False))

        # Build pytest command
        cmd_parts = ["python", "-m", "pytest", target, "-q", "--tb=short"]
        if fail_fast:
            cmd_parts.append("-x")
        if coverage:
            cmd_parts.extend(["--cov=.", "--cov-report=term-missing"])

        cmd = " ".join(cmd_parts)

        # Safety: blocked in read-only mode
        if self.read_only:
            return json.dumps({"error": "graq_test is blocked in read-only mode."})

        try:
            proc = subprocess.run(
                cmd,
                shell=True,
                capture_output=True,
                text=True,
                cwd=cwd,
                timeout=120,
            )
        except subprocess.TimeoutExpired:
            return json.dumps({"error": "pytest timed out after 120 seconds.", "tool": "graq_test"})
        except Exception as exc:
            return json.dumps({"error": str(exc), "tool": "graq_test"})

        stdout = proc.stdout
        stderr = proc.stderr
        exit_code = proc.returncode

        # Parse structured results from pytest -q output
        passed = failed = skipped = errors = 0

        # Match summary line: "5 passed, 2 failed, 1 skipped in 3.42s"
        summary_match = re.search(
            r"(\d+) passed(?:, (\d+) failed)?(?:, (\d+) skipped)?",
            stdout,
        )
        if summary_match:
            passed = int(summary_match.group(1) or 0)
            failed = int(summary_match.group(2) or 0)
            skipped = int(summary_match.group(3) or 0)
        else:
            # Try alternate: "no tests ran" or "x failed"
            fail_only = re.search(r"(\d+) failed", stdout)
            if fail_only:
                failed = int(fail_only.group(1))

        # Parse coverage if present
        coverage_pct: float | None = None
        if coverage:
            cov_match = re.search(r"TOTAL\s+\d+\s+\d+\s+(\d+)%", stdout)
            if cov_match:
                coverage_pct = float(cov_match.group(1))

        # Collect failing test IDs
        failing_tests: list[str] = re.findall(r"FAILED\s+([\w/.::\-]+)", stdout)

        # Duration
        duration_match = re.search(r"in ([\d.]+)s", stdout)
        duration_s = float(duration_match.group(1)) if duration_match else None

        metrics: dict[str, Any] = {
            "passed": passed,
            "failed": failed,
            "skipped": skipped,
            "exit_code": exit_code,
            "duration_s": duration_s,
            "coverage_pct": coverage_pct,
            "failing_tests": failing_tests,
            "status": "GREEN" if exit_code == 0 else "RED",
        }

        # Optionally write CodeMetric nodes to graph
        if record_metrics and self._graph is not None:
            try:
                metric_id = f"test_metrics_{target.replace('/', '_').replace('.', '_')}"
                self._graph.add_node_simple(
                    metric_id,
                    label=f"Test Metrics: {target}",
                    entity_type="CodeMetric",
                    description=(
                        f"Test run: {passed} passed, {failed} failed, {skipped} skipped. "
                        f"Status: {metrics['status']}"
                        + (f". Coverage: {coverage_pct}%" if coverage_pct is not None else "")
                    ),
                )
                metrics["graph_node_id"] = metric_id
            except Exception:
                pass

        return json.dumps({
            "tool": "graq_test",
            "target": target,
            "metrics": metrics,
            "stdout": stdout[-2000:] if len(stdout) > 2000 else stdout,
            "stderr": stderr[-500:] if len(stderr) > 500 else stderr,
        })

    async def _handle_plan(self, args: dict[str, Any]) -> str:
        """Handle graq_plan — goal decomposition into a governance-gated DAG plan.

        This handler is READ-ONLY. It produces a reviewable ExecutionPlan and
        writes it as an ExecutionPlan node into the knowledge graph. It does NOT
        execute any steps.

        Flow:
            1. Load graph + run impact analysis to find affected modules
            2. Use graph topology (causal_tiers, impact_radius) to order steps
            3. Assign risk levels based on impact_radius + _WRITE_TOOLS membership
            4. Insert GovernanceCheckpoints before HIGH/CRITICAL steps
            5. Estimate cost from TASK_RECOMMENDATIONS routing metadata
            6. Write ExecutionPlan node to graph (so future reasoning can reason about it)
            7. Return plan JSON for caller review
        """
        import uuid

        goal: str = args.get("goal", "").strip()
        if not goal:
            return json.dumps({"error": "graq_plan requires 'goal'", "tool": "graq_plan"})

        scope: str = args.get("scope", "")
        max_steps: int = int(args.get("max_steps", 15))
        include_tests: bool = bool(args.get("include_tests", True))
        require_approval_threshold: str = args.get("require_approval_threshold", "HIGH")
        dry_run: bool = bool(args.get("dry_run", False))

        # Import plan types (zero blast radius — new module)
        from graqle.core.plan import ExecutionPlan, GovernanceCheckpoint, PlanStep

        plan_id = f"plan_{uuid.uuid4().hex[:8]}"
        approval_levels = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
        approval_threshold_level = approval_levels.get(require_approval_threshold, 2)

        if dry_run:
            # Fast preview: return a skeleton plan without graph analysis
            steps = [
                PlanStep(
                    step_id="step_1",
                    tool="graq_impact",
                    description=f"Analyse impact of: {goal[:80]}",
                    args={"component": scope or goal[:40]},
                    risk_level="LOW",
                ),
                PlanStep(
                    step_id="step_2",
                    tool="graq_generate",
                    description="Generate code changes as unified diff",
                    args={"description": goal, "dry_run": True},
                    depends_on=["step_1"],
                    risk_level="MEDIUM",
                ),
                PlanStep(
                    step_id="step_3",
                    tool="graq_edit",
                    description="Apply generated diff to target file(s)",
                    args={"dry_run": True},
                    depends_on=["step_2"],
                    risk_level="MEDIUM",
                    requires_approval=True,
                ),
            ]
            if include_tests:
                steps.append(PlanStep(
                    step_id="step_4",
                    tool="graq_test",
                    description="Run test suite to verify changes",
                    args={"target": "tests/", "fail_fast": True},
                    depends_on=["step_3"],
                    risk_level="LOW",
                ))

            plan = ExecutionPlan(
                goal=goal,
                plan_id=plan_id,
                steps=steps,
                risk_level="MEDIUM",
                estimated_cost_usd=0.0,
                decomposition_confidence=0.5,
                requires_approval=any(s.requires_approval for s in steps),
            )
            return json.dumps({
                "tool": "graq_plan",
                "plan": plan.to_dict(),
                "dry_run": True,
                "note": "Dry-run: graph impact analysis skipped. Pass dry_run=false for full analysis.",
            })

        # Full plan: run impact analysis via existing graq_impact handler
        try:
            graph = self._load_graph()
        except Exception as exc:
            return json.dumps({"error": f"Failed to load graph: {exc}", "tool": "graq_plan"})

        # Step 1: impact analysis — find affected nodes
        impact_result_str = await self._handle_impact({
            "component": scope or goal[:60],
            "max_depth": 2,
        })
        try:
            import json as _json
            impact_data = _json.loads(impact_result_str)
            affected_modules: list[str] = []
            affected_files: list[str] = []
            if isinstance(impact_data, dict):
                # Extract module/file names from impact result
                for key in ("affected_modules", "modules", "components", "nodes"):
                    val = impact_data.get(key)
                    if isinstance(val, list):
                        affected_modules.extend(str(v) for v in val[:20])
                        break
                for key in ("files", "affected_files"):
                    val = impact_data.get(key)
                    if isinstance(val, list):
                        affected_files.extend(str(v) for v in val[:20])
                        break
        except Exception:
            affected_modules = []
            affected_files = []

        # Step 2: build DAG steps based on goal keywords and affected modules
        steps: list[PlanStep] = []
        checkpoints: list[GovernanceCheckpoint] = []
        step_counter = 0

        def _next_step_id() -> str:
            nonlocal step_counter
            step_counter += 1
            return f"step_{step_counter}"

        # Always start with impact analysis
        s_impact = _next_step_id()
        steps.append(PlanStep(
            step_id=s_impact,
            tool="graq_impact",
            description=f"Full impact analysis for: {goal[:80]}",
            args={"component": scope or goal[:60], "max_depth": 3},
            risk_level="LOW",
            estimated_cost_usd=0.001,
        ))

        # Preflight before any writes
        s_preflight = _next_step_id()
        steps.append(PlanStep(
            step_id=s_preflight,
            tool="graq_preflight",
            description="Run governance preflight check",
            args={"action": goal[:120]},
            depends_on=[s_impact],
            risk_level="LOW",
            estimated_cost_usd=0.001,
        ))

        # Read affected files for context
        if affected_files:
            for fpath in affected_files[:3]:
                s_read = _next_step_id()
                steps.append(PlanStep(
                    step_id=s_read,
                    tool="graq_read",
                    description=f"Read {fpath} for context",
                    args={"path": fpath},
                    depends_on=[s_preflight],
                    risk_level="LOW",
                    estimated_cost_usd=0.0,
                ))

        read_steps = [s.step_id for s in steps if s.tool == "graq_read"] or [s_preflight]

        # Determine if this is a generate/edit/refactor/review/debug type goal
        goal_lower = goal.lower()
        is_generative = any(kw in goal_lower for kw in (
            "add", "create", "generate", "implement", "write", "build", "fix",
            "refactor", "update", "modify", "change", "rename", "migrate",
        ))
        is_analysis = any(kw in goal_lower for kw in (
            "analyse", "analyze", "review", "audit", "check", "inspect",
            "find", "detect", "list", "show", "understand",
        ))

        if is_generative:
            # Generate diff
            s_gen = _next_step_id()
            steps.append(PlanStep(
                step_id=s_gen,
                tool="graq_generate",
                description=f"Generate code changes for: {goal[:80]}",
                args={"description": goal, "dry_run": True},
                depends_on=read_steps,
                risk_level="MEDIUM",
                estimated_cost_usd=0.01,
            ))

            # Governance checkpoint before write
            cp_id = f"cp_{len(checkpoints) + 1}"
            checkpoints.append(GovernanceCheckpoint(
                checkpoint_id=cp_id,
                before_step_id=f"step_{step_counter + 1}",
                check_type="approval",
                description="Review generated diff before applying to files",
                blocking=True,
            ))

            # Apply diff
            s_edit = _next_step_id()
            # Fix checkpoint to point to correct step
            checkpoints[-1] = GovernanceCheckpoint(
                checkpoint_id=cp_id,
                before_step_id=s_edit,
                check_type="approval",
                description="Review generated diff before applying to files",
                blocking=True,
            )
            requires_approval = approval_levels.get("MEDIUM", 1) >= approval_threshold_level
            steps.append(PlanStep(
                step_id=s_edit,
                tool="graq_edit",
                description="Apply diff to target files (dry_run=true for safety)",
                args={"dry_run": True},
                depends_on=[s_gen],
                risk_level="MEDIUM",
                requires_approval=requires_approval,
                gate_name="validate_diff_format",
                estimated_cost_usd=0.001,
            ))

            if include_tests:
                s_test = _next_step_id()
                steps.append(PlanStep(
                    step_id=s_test,
                    tool="graq_test",
                    description="Run test suite to verify changes",
                    args={"target": "tests/", "fail_fast": True},
                    depends_on=[s_edit],
                    risk_level="LOW",
                    gate_name="test_coverage_gate",
                    estimated_cost_usd=0.0,
                ))

            # Learn from outcome
            s_learn = _next_step_id()
            steps.append(PlanStep(
                step_id=s_learn,
                tool="graq_learn",
                description="Record outcome in knowledge graph",
                args={"action": goal[:120], "mode": "outcome"},
                depends_on=[steps[-1].step_id],
                risk_level="LOW",
                estimated_cost_usd=0.001,
            ))

        elif is_analysis:
            # Analysis-only plan
            s_grep = _next_step_id()
            steps.append(PlanStep(
                step_id=s_grep,
                tool="graq_grep",
                description=f"Search codebase for patterns related to: {goal[:60]}",
                args={"pattern": goal[:40], "path": "."},
                depends_on=read_steps,
                risk_level="LOW",
                estimated_cost_usd=0.0,
            ))
            s_review = _next_step_id()
            steps.append(PlanStep(
                step_id=s_review,
                tool="graq_review",
                description=f"Review findings and produce structured analysis",
                args={"target": scope or ".", "focus": goal[:120]},
                depends_on=[s_grep],
                risk_level="LOW",
                estimated_cost_usd=0.01,
            ))

        # Enforce max_steps
        if len(steps) > max_steps:
            steps = steps[:max_steps]

        # Overall risk = highest step risk
        risk_priority = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
        overall_risk = max(steps, key=lambda s: risk_priority.get(s.risk_level, 0)).risk_level if steps else "LOW"
        total_cost = sum(s.estimated_cost_usd for s in steps)
        requires_plan_approval = any(s.requires_approval for s in steps)

        plan = ExecutionPlan(
            goal=goal,
            plan_id=plan_id,
            steps=steps,
            checkpoints=checkpoints,
            risk_level=overall_risk,
            estimated_cost_usd=total_cost,
            affected_files=affected_files[:10],
            affected_modules=affected_modules[:10],
            requires_approval=requires_plan_approval,
            decomposition_confidence=0.75 if affected_modules else 0.5,
        )

        # Write ExecutionPlan node to KG so future reasoning can reason about the plan
        try:
            plan_dict = plan.to_dict()
            graph.add_node_simple(
                node_id=plan_id,
                label=f"Plan: {goal[:60]}",
                node_type="ExecutionPlan",
                metadata={
                    "goal": goal,
                    "risk_level": overall_risk,
                    "total_steps": len(steps),
                    "estimated_cost_usd": total_cost,
                    "requires_approval": requires_plan_approval,
                },
            )
        except Exception:
            pass  # Never fail on KG write — plan is still useful without it

        return json.dumps({
            "tool": "graq_plan",
            "plan": plan.to_dict(),
            "dry_run": False,
            "affected_modules_found": len(affected_modules),
            "affected_files_found": len(affected_files),
        })

    # ── graq_profile (Phase 7) ─────────────────────────────────────────
    # v0.38.0 — reasoning performance profiler + CodeMetric KG node writer

    async def _handle_profile(self, args: dict[str, Any]) -> str:
        """Profile a graq_reason invocation: per-step latency, tokens, confidence.

        Args:
            query: reasoning query to profile (required)
            max_rounds: LLM rounds to run (default 2)
            session_label: human-readable label for KG node
            write_kg_node: if True, write CodeMetric node to KG (default True)
            include_step_breakdown: if True, include step details in response

        Returns:
            JSON of ProfileSummary.to_dict()
        """
        import time as _time
        from graqle.core.profiler import Profiler, ProfileConfig

        query: str = args.get("query", "").strip()
        if not query:
            return json.dumps({"error": "graq_profile requires 'query'", "tool": "graq_profile"})

        max_rounds = min(max(int(args.get("max_rounds", 2)), 1), 5)
        session_label = str(args.get("session_label", "")) or f"graq_profile:{query[:40]}"
        write_kg_node = bool(args.get("write_kg_node", True))
        include_step_breakdown = bool(args.get("include_step_breakdown", True))

        profiler = Profiler(ProfileConfig(include_step_breakdown=include_step_breakdown))
        trace = profiler.new_trace(session_label=session_label, query=query)

        # Load graph
        try:
            graph = self._load_graph()
        except Exception as exc:
            return json.dumps({"error": f"Failed to load graph: {exc}", "tool": "graq_profile"})

        # Run graq_reason with timing instrumentation per phase.
        # We approximate phase boundaries by measuring the full areason() call
        # and recording it as a single REASON step. Future iterations will
        # add sub-phase hooks when the reasoning engine exposes callbacks.
        t0_ns = _time.perf_counter_ns()
        reason_result = None
        reason_error: str = ""
        try:
            reason_result = await graph.areason(
                query,
                max_rounds=max_rounds,
                task_type="reason",
            )
        except Exception as exc:
            reason_error = str(exc)[:200]

        # Record REASON step
        latency_ms = (_time.perf_counter_ns() - t0_ns) / 1_000_000
        tokens_used = 0
        confidence = 0.0
        model_used = ""

        if reason_result is not None:
            # Estimate token usage from cost (rough: $3/1M tokens for claude-sonnet)
            cost_usd = getattr(reason_result, "cost_usd", 0.0)
            tokens_used = int(cost_usd / 0.000003) if cost_usd > 0 else 0
            confidence = float(getattr(reason_result, "confidence", 0.0))
            model_used = str(getattr(reason_result, "model", ""))
            if not model_used:
                try:
                    cfg_obj = self._config
                    model_used = getattr(cfg_obj, "default_model", "") if cfg_obj else ""
                except Exception:
                    pass

        trace.record_step(
            step_name="REASON",
            latency_ms=latency_ms,
            tokens_used=tokens_used,
            confidence=confidence,
            model=model_used,
            notes=reason_error or (
                f"rounds={getattr(reason_result, 'rounds_completed', '?')}, "
                f"active_nodes={len(getattr(reason_result, 'active_nodes', []))}"
                if reason_result else ""
            ),
        )

        summary = profiler.finish(trace)

        # Write CodeMetric KG node
        if write_kg_node:
            try:
                from graqle.core.node import CogniNode
                ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
                metric_node = CogniNode(
                    label=f"profile_{ts}",
                    node_type="CodeMetric",
                    metadata=trace.to_node_metadata(),
                )
                graph.add_node(metric_node)
                self._save_graph(graph)
                summary.kg_node_written = True
            except Exception:
                pass  # Never fail on KG write — profile is still useful without it

        result = summary.to_dict()
        result["tool"] = "graq_profile"
        if not include_step_breakdown:
            result.pop("step_breakdown", None)
        if reason_error:
            result["reason_error"] = reason_error
        return json.dumps(result)

    def _read_active_branch(self) -> str | None:
        """Read .gcc/registry.md to find the active branch, if present."""
        registry = Path(".gcc/registry.md")
        if not registry.exists():
            return None

        try:
            content = registry.read_text(encoding="utf-8")
            # Extract active branch from table rows
            lines = content.strip().split("\n")
            active_branches: list[str] = []
            for line in lines:
                lower = line.lower()
                if "working" in lower or "active" in lower:
                    active_branches.append(line.strip())
            if active_branches:
                return "\n".join(active_branches)
        except Exception:
            pass
        return None

    def _save_graph(self, graph: Any) -> None:
        """Persist graph back to its source JSON file.

        ADR-123 Phase 2: After every local write, schedule a background S3 push
        so learned nodes are never lost on restart or machine change.
        """
        if self._graph_file is None:
            return

        try:
            import networkx as nx

            G = graph.to_networkx()
            data = nx.node_link_data(G, edges="links")
            from graqle.core.graph import _write_with_lock
            _write_with_lock(str(self._graph_file), json.dumps(data, indent=2, default=str))
            logger.info("Graph saved to %s", self._graph_file)
        except Exception as exc:
            logger.error("Failed to save graph: %s", exc)
            return

        # ADR-123 Phase 2: background push to S3 (non-blocking, debounced)
        try:
            from graqle.core.kg_sync import schedule_push, _detect_project_name
            from pathlib import Path as _Path
            _proj = _detect_project_name(_Path(self._graph_file).parent)
            schedule_push(self._graph_file, _proj)
        except Exception as _push_exc:
            logger.debug("KG background push skipped: %s", _push_exc)

    # ==================================================================
    # MCP JSON-RPC stdio transport
    # ==================================================================

    async def _handle_jsonrpc(self, request: dict[str, Any]) -> dict[str, Any] | None:
        """Handle a single JSON-RPC request."""
        method = request.get("method", "")
        req_id = request.get("id")
        params = request.get("params", {})

        # ---- MCP lifecycle methods ------------------------------------

        if method == "initialize":
            _project_ctx: dict = {}
            try:
                _g = self._load_graph()
                if _g is not None:
                    _project_ctx = _g.project_context()
            except Exception:
                pass
            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {
                    "protocolVersion": "2024-11-05",
                    "capabilities": {
                        "tools": {"listChanged": False},
                    },
                    "serverInfo": {
                        "name": "graq",
                        "version": _version,
                    },
                    "projectContext": _project_ctx,
                },
            }

        if method == "notifications/initialized":
            # Client confirms initialization — no response needed
            return None

        if method == "tools/list":
            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {"tools": self.list_tools()},
            }

        if method == "tools/call":
            tool_name = params.get("name", "")
            arguments = params.get("arguments", {})
            try:
                result_text = await self.handle_tool(tool_name, arguments)
                return {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "result": {
                        "content": [{"type": "text", "text": result_text}],
                        "isError": False,
                    },
                }
            except Exception as exc:
                return {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "result": {
                        "content": [{"type": "text", "text": json.dumps({"error": str(exc)})}],
                        "isError": True,
                    },
                }

        # Unrecognized method — return error only for requests with an id
        if req_id is not None:
            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "error": {
                    "code": -32601,
                    "message": f"Method not found: {method}",
                },
            }
        return None

    async def run_stdio(self) -> None:
        """Run the MCP server over stdio (JSON-RPC, newline-delimited).

        Reads JSON-RPC requests from stdin (one per line),
        writes JSON-RPC responses to stdout (one per line).
        Diagnostic logging goes to stderr.

        Uses a thread-based approach for stdin reads to avoid
        ``loop.connect_read_pipe`` / ``connect_write_pipe`` which crash on
        Windows ProactorEventLoop (OSError: [WinError 6] The handle is
        invalid).  Works on Windows, Linux, and macOS.
        """
        # Redirect logging to stderr so stdout stays clean for JSON-RPC
        # Bug 16 fix: only add handler if none exists yet (prevents duplicate logs)
        cg_logger = logging.getLogger("graqle")
        if not cg_logger.handlers:
            handler = logging.StreamHandler(sys.stderr)
            handler.setLevel(logging.INFO)
            handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
            cg_logger.addHandler(handler)

        logger.info("KogniDevServer starting on stdio transport (v%s)", _version)

        # Version mismatch detection: warn if a previous MCP was running a different version
        try:
            version_file = Path(".graqle/mcp.version")
            if version_file.exists():
                last_version = version_file.read_text(encoding="utf-8").strip()
                if last_version and last_version != _version:
                    logger.warning(
                        "VERSION MISMATCH: Previous MCP server was v%s, "
                        "now starting v%s. If you just upgraded, this is expected. "
                        "If not, run 'graq self-update' to align versions.",
                        last_version,
                        _version,
                    )
        except Exception:
            pass  # Non-critical

        loop = asyncio.get_event_loop()

        # -- Cross-platform stdin reading via a background thread ----------
        # ``sys.stdin.buffer.readline()`` is blocking, so we run it in the
        # default ThreadPoolExecutor and await the future.

        import functools

        def _blocking_readline() -> bytes:
            """Read one line from stdin (blocking).  Returns b'' on EOF."""
            try:
                return sys.stdin.buffer.readline()
            except (OSError, ValueError):
                # stdin closed or invalid
                return b""

        def _write_stdout(data: bytes) -> None:
            """Write *data* to stdout and flush (blocking-safe)."""
            try:
                sys.stdout.buffer.write(data)
                sys.stdout.buffer.flush()
            except (OSError, ValueError):
                pass  # stdout closed

        while True:
            try:
                line = await loop.run_in_executor(None, _blocking_readline)
                if not line:
                    break  # EOF — client disconnected

                line_str = line.decode("utf-8").strip()
                if not line_str:
                    continue

                request = json.loads(line_str)
                response = await self._handle_jsonrpc(request)

                if response is not None:
                    out = (json.dumps(response) + "\n").encode("utf-8")
                    await loop.run_in_executor(
                        None, functools.partial(_write_stdout, out)
                    )

            except json.JSONDecodeError as exc:
                error_resp = {
                    "jsonrpc": "2.0",
                    "error": {"code": -32700, "message": f"Parse error: {exc}"},
                    "id": None,
                }
                out = (json.dumps(error_resp) + "\n").encode("utf-8")
                await loop.run_in_executor(
                    None, functools.partial(_write_stdout, out)
                )

            except Exception as exc:
                logger.exception("Unhandled error in stdio loop")
                error_resp = {
                    "jsonrpc": "2.0",
                    "error": {"code": -32603, "message": str(exc)},
                    "id": None,
                }
                try:
                    out = (json.dumps(error_resp) + "\n").encode("utf-8")
                    await loop.run_in_executor(
                        None, functools.partial(_write_stdout, out)
                    )
                except Exception:
                    break  # stdout broken, exit

        logger.info("KogniDevServer shutting down")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _context_limit(level: str) -> int:
    """Number of nodes to include at each context level."""
    return {"minimal": 3, "standard": 6, "deep": 12}.get(level, 6)


# ---------------------------------------------------------------------------
# Entry point (for direct invocation)
# ---------------------------------------------------------------------------


def main(config_path: str = "graqle.yaml") -> None:
    """Start the MCP dev server on stdio."""
    server = KogniDevServer(config_path=config_path)
    asyncio.run(server.run_stdio())


if __name__ == "__main__":
    main()
