#!/usr/bin/env python3
"""Pipeline stage validator — structural enforcement for the pipeline orchestrator.

Enforces 8 invariants after each pipeline stage to prevent behavioral drift
in the prompt-driven orchestrator. Called via bash after every stage:

    python pipeline_validator.py check \\
        --project SwarmAI --run-id run_xxx --stage evaluate

Returns JSON:
    {"valid": true, "stage": "evaluate", "errors": [], "warnings": [],
     "checks_passed": 8, "checks_total": 8}

Errors (BLOCK) prevent stage advancement. Warnings are informational —
they surface in the delivery report but don't block progress.

The 13 invariant checks:
    1. Stage order     — current stage follows the last completed stage per profile
    2. Artifact exists — stage published an artifact (except reflect)
    3. Artifact schema — required fields present in artifact JSON
    4. Decision logged — at least 1 decision classified in StageRecord
    5. Budget recorded — token_cost > 0 in stage record
    6. Profile respected — stage is in the selected profile
    7. DDD consistency — cross-document checks: non-goals vs approach,
                          failed patterns vs plan, staleness detection
                          (WARN only, evaluate stage)
    8. Quality gate    — stage-specific structural enforcement:
                          8a. BUILD: smoke_tests > 0 when files_changed > 1
                          8b. REVIEW: integration_trace.checked > 0
                          8c. REVIEW: ux_review when frontend files in changeset
                          8d. REVIEW: findings_count required for large changesets
    9. Depth (L2)     — field values indicate real work (not just structure)
   10. Push-ready (L3)— binary verdict gate for deliver stage
   11. Semantic (L2.5)— content quality heuristics (WARN)
   12. Anti-rational.  — skips require structured justification; counter-arguments
                          block the skip (earned truths from EVOLUTION.md)
   13. Output routing  — stages must consume declared upstream artifacts;
                          freshness check warns on stale consumed artifacts

Public symbols:
- ``main``              — CLI entry point
- ``validate``          — Core validation logic (testable without CLI)
- ``check_ddd_consistency`` — Standalone DDD cross-doc check (testable without pipeline)
- ``check_ddd_staleness``  — Detect runs whose DDD docs changed since evaluation
"""

import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any

# Add parent directory for core imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from core.pipeline_profiles import get_profile_stages


# ---------------------------------------------------------------------------
# Stage artifact schemas — required fields produce BLOCK, recommended produce WARN
# ---------------------------------------------------------------------------

STAGE_SCHEMAS: dict[str, dict[str, list[str]]] = {
    "evaluate": {
        "required": ["recommendation", "scope"],
        "recommended": ["acceptance_criteria", "scores"],
    },
    "think": {
        "required": ["key_findings"],
        "recommended": ["alternatives", "sources"],
    },
    "plan": {
        "required": ["acceptance_criteria"],
        "recommended": ["approach", "data_model", "boundaries", "success_criteria"],
    },
    "build": {
        "required": ["files_changed", "tdd"],  # V1.10.0: tdd metadata required
        "recommended": ["commits", "diff_summary"],
        # tdd.smoke_tests must be > 0 when files_changed > 1 (Check 8)
    },
    "review": {
        "required": [
            "approved",
            "integration_trace",
            "runtime_patterns",   # V1.10.0: RP1-RP29 checklist evidence
            "findings_count",     # V1.10.0: explicit count (even if 0)
        ],
        "recommended": ["findings", "security_findings", "ux_review",
                         "operational_patterns", "wire_test"],
    },
    "test": {
        "required": ["passed"],
        "recommended": ["failed", "fixed", "coverage"],
    },
    "deliver": {
        "required": [
            "title",
            "quality",             # V2: binary push_ready gate (replaces confidence_score)
            # adversarial_review + completion_audit enforced by STAGE_DEPTH (profile-aware).
            # DEPTH blocks full/bugfix if absent, exempts trivial/research/docs.
        ],
        "recommended": ["decisions", "attention_flags", "report_path",
                         "meta_review"],
    },
    # reflect has no artifact — skip schema check
}

# Stage schema templates — show expected JSON shape for publish-time errors
STAGE_TEMPLATES: dict[str, dict] = {
    "evaluate": {
        "recommendation": "GO|DEFER|REJECT",
        "scope": "trivial|standard|complex",
        "acceptance_criteria": ["AC1: ...", "AC2: ..."],
        "scores": {"strategic": 0, "priority": 0, "feasibility": 0},
    },
    "think": {
        "key_findings": ["finding 1", "finding 2"],
        "alternatives": [{"constraint": "SPEED|QUALITY|SIMPLICITY", "what": "...", "effort": "S|M|L"}],
        "recommendation": "Approach X because...",
    },
    "plan": {
        "acceptance_criteria": ["AC1: testable statement"],
        "approach": "description",
        "boundaries": {"always": [], "ask_first": [], "never": []},
        "success_criteria": ["verifiable condition"],
    },
    "build": {
        "files_changed": ["path/to/file.py"],
        "tdd": {"green_pass": True, "smoke_tests": 3, "red_count": 5, "green_count": 5},
        "commits": ["abc1234"],
    },
    "review": {
        "approved": True,
        "findings_count": 2,
        "integration_trace": {"checked": 5, "clean": True, "details": "symbol A → caller B verified"},
        "runtime_patterns": {
            "checked": 3, "violations": 0,
            "patterns": [{"pattern": "name", "status": "pass|N/A", "detail": ">10 chars describing what was checked"}],
        },
    },
    "test": {
        "passed": True,
        "tests_new": 10, "tests_total": 50, "regressions": 0,
    },
    "deliver": {
        "title": "Feature Name",
        "quality": {"tests_pass": True, "regressions": 0, "smoke_pass": True},
        "adversarial_review": {
            "spawned": True, "profile_tier": "full|lite|skipped",
            "findings_total": 3, "findings_fixed": 3, "findings_remaining": 0,
            "findings": [{"severity": "HIGH|MEDIUM|LOW", "resolved": True,
                          "finding": "path/to/file.py function_name() line N: what is wrong. Fixed: how."}],
        },
        "completion_audit": {"all_green": True, "requirements_met": 5, "requirements_total": 5},
    },
}

# Depth requirements: nested fields that must exist for depth validation to pass
STAGE_DEPTH: dict[str, dict[str, list[str]]] = {
    "build": {"tdd": ["green_pass", "smoke_tests"]},
    "review": {"runtime_patterns": ["checked", "patterns"], "integration_trace": ["checked"]},
    "deliver": {
        "adversarial_review": ["profile_tier", "findings"],
        "completion_audit": ["all_green"],
    },
}


def get_stage_schema(stage: str) -> dict:
    """Public API: return schema + template for a stage.

    Used by artifact_cli publish --stage for pre-publish validation.
    Single source of truth — no duplicate definitions elsewhere.
    """
    schema = STAGE_SCHEMAS.get(stage, {})
    template = STAGE_TEMPLATES.get(stage, {})
    depth = STAGE_DEPTH.get(stage, {})
    return {
        "required": schema.get("required", []),
        "recommended": schema.get("recommended", []),
        "depth": depth,
        "template": template,
    }


def validate_artifact_data(stage: str, data: dict, profile: str = "full") -> list[str]:
    """Public API: validate artifact data against stage schema.

    Returns list of error strings. Empty list = valid.
    Checks: required fields, depth nested fields, stage-specific invariants.
    Used by artifact_cli at publish time for fail-fast validation.

    Args:
        stage: Pipeline stage name (evaluate, think, plan, build, review, test, deliver)
        data: Artifact data dict to validate
        profile: Pipeline profile (full, bugfix, trivial, research, docs). Defaults to
                 "full" which is the strictest — trivial/research/docs exempt from
                 adversarial review requirements.
    """
    errors: list[str] = []
    schema = STAGE_SCHEMAS.get(stage)
    if not schema:
        return []

    # Required fields
    for field in schema.get("required", []):
        if field not in data:
            errors.append(f"Missing required field '{field}' for stage '{stage}'")

    # Depth: nested required fields
    # Absent parent field = depth requirement FAILED (not silently skipped)
    for parent_field, child_fields in STAGE_DEPTH.get(stage, {}).items():
        parent_val = data.get(parent_field)
        if parent_val is None:
            # Parent field entirely absent — this IS the violation
            # Exception: trivial/research/docs profiles can skip adversarial
            _skip_profiles = ("trivial", "research", "docs")
            if profile not in _skip_profiles:
                errors.append(
                    f"Missing required field '{parent_field}' — "
                    f"depth validation requires this for {stage} stage "
                    f"(profile={profile}). Was the step actually executed?"
                )
        elif isinstance(parent_val, dict):
            for child in child_fields:
                if child not in parent_val:
                    errors.append(
                        f"Missing '{parent_field}.{child}' — required for depth validation"
                    )
                elif (
                    isinstance(parent_val[child], list)
                    and len(parent_val[child]) == 0
                    and child == "patterns"  # PE-4: Only patterns must be non-empty
                ):
                    # patterns list must have entries (even N/A results).
                    # findings list can legitimately be empty (clean review).
                    errors.append(
                        f"'{parent_field}.{child}' is empty — must contain at least one entry"
                    )

    # Stage-specific invariants (subset of _check_depth for fast feedback)
    if stage == "deliver":
        ar = data.get("adversarial_review")
        if isinstance(ar, dict):
            tier = ar.get("profile_tier", "")
            # Tier=skipped on full/bugfix → BLOCK (C026 fix)
            _strict_profiles = ("full", "bugfix", "standard", "")
            if tier in ("skipped", "lite") and profile in _strict_profiles:
                errors.append(
                    f"adversarial_review.profile_tier='{tier}' but profile='{profile}' "
                    f"requires full adversarial review. Only trivial/research/docs exempt."
                )
            elif tier and tier != "skipped":
                findings = ar.get("findings", [])
                if isinstance(findings, list):
                    unresolved = [f for f in findings if isinstance(f, dict)
                                  and f.get("severity") == "HIGH" and not f.get("resolved")]
                    if unresolved:
                        errors.append(
                            f"{len(unresolved)} unresolved HIGH finding(s) — "
                            f"set 'resolved': true after fixing each"
                        )

        # --- Three-Layer Governance: Full deliver ceremony enforcement ---
        # For full/bugfix profiles, ALL deliver sub-steps must have evidence.
        # This prevents the C028 pattern: running adversarial but skipping
        # completion audit, meta-review, and convergence loop.
        if profile in ("full", "bugfix", "standard", ""):
            # Check: completion_audit must exist and be all_green
            ca = data.get("completion_audit")
            if not isinstance(ca, dict):
                errors.append(
                    "completion_audit missing from deliver artifact. "
                    "Run Completion Audit (AC → evidence verification) before declaring done."
                )
            elif not ca.get("all_green"):
                gaps = ca.get("gaps", "unknown")
                errors.append(
                    f"completion_audit.all_green=false (gaps={gaps}). "
                    f"Fix gaps before declaring push-ready."
                )

            # Check: meta_review must exist (verdict CLEAR or risks addressed)
            mr = data.get("meta_review")
            if not mr and not data.get("meta_review_verdict"):
                errors.append(
                    "meta_review missing from deliver artifact. "
                    "Run Meta-Review (pipeline blind spot analysis) before declaring done."
                )

            # Check: convergence evidence (iterations + all_pass)
            conv = data.get("convergence")
            if not isinstance(conv, dict):
                errors.append(
                    "convergence missing from deliver artifact. "
                    "Run Quality Convergence Loop (6-layer gate) before declaring done."
                )
            elif not conv.get("all_pass") and conv.get("final_status") != "push-ready":
                errors.append(
                    f"convergence.all_pass=false — Quality Convergence Loop did not pass. "
                    f"Iterations: {conv.get('iterations', '?')}"
                )

    if stage == "build":
        tdd = data.get("tdd")
        if isinstance(tdd, dict):
            files = data.get("files_changed", [])
            smoke = tdd.get("smoke_tests", 0)
            if isinstance(files, list) and len(files) > 1 and smoke == 0:
                errors.append(
                    f"smoke_tests=0 but {len(files)} files changed — "
                    f"run smoke tests with real objects"
                )

    return errors


# ---------------------------------------------------------------------------
# Stage routing — explicit I/O contracts per stage (Check 12/13)
# ---------------------------------------------------------------------------

STAGE_ROUTING: dict[str, dict[str, list[str]]] = {
    "evaluate": {
        "consumes": [],
        "produces": ["evaluation"],
        "optional_produces": [],
    },
    "think": {
        "consumes": ["evaluation"],
        "produces": ["research"],
        "optional_produces": ["alternatives"],
    },
    "plan": {
        "consumes": ["evaluation", "research"],
        "produces": ["design_doc"],
        "optional_produces": [],
    },
    "build": {
        "consumes": ["design_doc"],
        "produces": ["changeset"],
        "optional_produces": [],
    },
    "review": {
        "consumes": ["changeset"],
        "produces": ["review"],
        "optional_produces": ["security_review"],
    },
    "test": {
        # F2: design_doc removed — test stage runs tests against changeset,
        # informed by review findings. design_doc is for build (implementation).
        "consumes": ["changeset", "review"],
        "produces": ["test_report"],
        "optional_produces": [],
    },
    "deliver": {
        "consumes": ["changeset", "review", "test_report"],
        "produces": ["delivery"],
        "optional_produces": ["report"],
    },
    "reflect": {
        "consumes": ["test_report", "delivery"],
        "produces": [],
        "optional_produces": [],
    },
}

# Default TTL for artifact freshness (hours) — 7 days
_DEFAULT_TTL_HOURS = 168


# Stages that never require an artifact (regardless of profile)
NO_ARTIFACT_STAGES = {"reflect"}

# Stages where artifact is optional for bugfix/trivial profiles
# (reduces ceremony — validator warns instead of blocking)
ARTIFACT_OPTIONAL_FOR_PROFILES: dict[str, set[str]] = {
    "bugfix": {"plan", "build", "review", "test", "deliver"},
    "trivial": {"build", "review", "test", "deliver"},
}

# Stages where decisions are optional (informational stages)
DECISION_OPTIONAL_STAGES = {"reflect", "deliver"}

# Code file extensions for changeset analysis
_CODE_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".rs", ".go", ".java", ".sh"}

# Evidence file extensions for semantic depth checks (includes docs + config)
_EVIDENCE_FILE_EXTS = (".py", ".ts", ".tsx", ".js", ".jsx", ".rs", ".go", ".md", ".json", ".yaml", ".sh")


# ---------------------------------------------------------------------------
# Workspace resolution
# ---------------------------------------------------------------------------

def _get_workspace() -> Path:
    import os
    from config import get_app_data_dir
    ws = os.environ.get("SWARM_WORKSPACE", str(get_app_data_dir() / "SwarmWS"))
    return Path(ws).expanduser().resolve()


def _get_artifacts_dir(project: str) -> Path | None:
    """Get the .artifacts/ directory for a project."""
    ws = _get_workspace()
    artifacts_dir = ws / "Projects" / project / ".artifacts"
    return artifacts_dir if artifacts_dir.is_dir() else None


def _load_run(project: str, run_id: str) -> dict[str, Any] | None:
    """Load a pipeline run from .artifacts/runs/<run_id>/run.json."""
    ws = _get_workspace()
    run_file = ws / "Projects" / project / ".artifacts" / "runs" / run_id / "run.json"
    if not run_file.exists():
        return None
    return json.loads(run_file.read_text())


def _load_artifact_data(project: str, run_id: str, artifact_id: str) -> dict[str, Any] | None:
    """Load artifact data file by artifact_id via manifest lookup.

    Note: run_id is accepted for call-site convenience but not used —
    manifest lookup is by artifact_id only (covers both run-scoped and
    top-level artifacts).
    """
    ws = _get_workspace()
    artifacts_dir = ws / "Projects" / project / ".artifacts"

    # Lookup via manifest (covers both run-scoped and top-level artifacts)
    manifest_file = artifacts_dir / "manifest.json"
    if not manifest_file.exists():
        return None

    try:
        manifest = json.loads(manifest_file.read_text())
    except (json.JSONDecodeError, OSError):
        return None

    for entry in manifest.get("artifacts", []):
        if entry.get("id") == artifact_id:
            data_file = artifacts_dir / entry.get("file", "")
            if data_file.exists():
                try:
                    return json.loads(data_file.read_text())
                except (json.JSONDecodeError, OSError):
                    return None
    return None


# ---------------------------------------------------------------------------
# DDD cross-document consistency
# ---------------------------------------------------------------------------

def _parse_non_goals(product_text: str) -> list[str]:
    """Extract non-goal keywords from PRODUCT.md's Non-Goals section.

    Returns lowercase keyword phrases (e.g. ["cloud saas", "general chatbot"]).
    """
    non_goals: list[str] = []
    in_section = False
    for line in product_text.splitlines():
        stripped = line.strip()
        # Detect ## Non-Goals header
        if re.match(r"^##\s+Non[- ]?Goals", stripped, re.IGNORECASE):
            in_section = True
            continue
        # Exit on next ## header
        if in_section and stripped.startswith("## "):
            break
        if in_section and stripped.startswith("- "):
            # Extract the bold part or the whole line
            bold = re.findall(r"\*\*([^*]+)\*\*", stripped)
            if bold:
                non_goals.extend(b.strip().lower() for b in bold)
            else:
                # Use the line content after "- "
                non_goals.append(stripped[2:].strip().lower())
    return non_goals


def _parse_failed_patterns(improvement_text: str) -> list[str]:
    """Extract failed pattern descriptions from IMPROVEMENT.md's What Failed section.

    Returns lowercase summary phrases from each bullet.
    """
    patterns: list[str] = []
    in_section = False
    for line in improvement_text.splitlines():
        stripped = line.strip()
        if re.match(r"^##\s+What Failed", stripped, re.IGNORECASE):
            in_section = True
            continue
        if in_section and stripped.startswith("## "):
            break
        if in_section and stripped.startswith("- "):
            # Extract bold summary or first sentence
            bold = re.findall(r"\*\*([^*]+)\*\*", stripped)
            if bold:
                for b in bold:
                    b_clean = b.strip().lower()
                    # Skip date-only entries (auto-writeback noise)
                    if re.match(r"^\d{4}-\d{2}-\d{2}$", b_clean):
                        continue
                    # Skip very short entries (< 10 chars, likely noise)
                    if len(b_clean) < 10:
                        continue
                    patterns.append(b_clean)
            else:
                text = stripped[2:].strip()
                # Take first sentence or up to 120 chars
                first_sentence = re.split(r"[.!?]", text)[0].strip()
                if first_sentence and len(first_sentence) >= 10:
                    patterns.append(first_sentence.lower()[:120])
    return patterns


def _compute_doc_checksum(text: str) -> str:
    """Compute a stable checksum for DDD document content (ignores whitespace variance)."""
    normalized = re.sub(r"\s+", " ", text.strip())
    return hashlib.md5(normalized.encode()).hexdigest()[:12]


def check_ddd_consistency(project: str, context_text: str | None = None) -> dict[str, Any]:
    """Cross-validate DDD documents for a project. Works standalone or within pipeline.

    Checks:
      1. Non-goals (PRODUCT.md) vs architecture description (TECH.md)
         — flags if non-goal keywords appear in TECH.md architecture section
      2. Failed patterns (IMPROVEMENT.md) existence check
         — warns if no failed patterns recorded (empty learning)
      3. Document staleness — computes checksums for change detection

    Args:
        project: Project name (directory under Projects/)
        context_text: Optional text to check against non-goals (e.g. evaluation summary).
                      If provided, also checks this text against non-goals.

    Returns:
        {"warnings": [...], "checksums": {"PRODUCT.md": "abc...", ...},
         "non_goals": [...], "failed_patterns": [...]}
    """
    ws = _get_workspace()
    project_dir = ws / "Projects" / project
    warnings: list[str] = []
    checksums: dict[str, str] = {}
    non_goals: list[str] = []
    failed_patterns: list[str] = []

    # Load DDD docs
    ddd_docs: dict[str, str] = {}
    for doc_name in ("PRODUCT.md", "TECH.md", "IMPROVEMENT.md", "PROJECT.md"):
        doc_path = project_dir / doc_name
        if doc_path.exists():
            try:
                content = doc_path.read_text()
                ddd_docs[doc_name] = content
                checksums[doc_name] = _compute_doc_checksum(content)
            except OSError:
                warnings.append(f"DDD: Could not read {doc_name} for project '{project}'")

    if not ddd_docs:
        return {
            "warnings": [f"DDD: No DDD documents found for project '{project}' — skipping consistency check"],
            "checksums": {},
            "non_goals": [],
            "failed_patterns": [],
        }

    # Check 1: Non-goals vs TECH.md architecture
    if "PRODUCT.md" in ddd_docs:
        non_goals = _parse_non_goals(ddd_docs["PRODUCT.md"])

    if non_goals and "TECH.md" in ddd_docs:
        tech_text = ddd_docs["TECH.md"].lower()
        # Only check the Architecture section of TECH.md (not the whole doc)
        arch_section = _extract_section(tech_text, "architecture")
        check_text = arch_section if arch_section else tech_text[:2000]

        for ng in non_goals:
            # Extract meaningful keywords (skip short/common words)
            keywords = [w for w in ng.split() if len(w) > 3 and w not in
                        {"not", "just", "only", "that", "this", "with", "from",
                         "have", "been", "does", "about", "into", "more", "than"}]
            for kw in keywords:
                if kw in check_text:
                    warnings.append(
                        f"DDD conflict: Non-goal '{ng}' keyword '{kw}' "
                        f"appears in TECH.md architecture — verify alignment"
                    )

    # Check 1b: Non-goals vs context_text (e.g. evaluation approach)
    if non_goals and context_text:
        ctx_lower = context_text.lower()
        for ng in non_goals:
            keywords = [w for w in ng.split() if len(w) > 3 and w not in
                        {"not", "just", "only", "that", "this", "with", "from",
                         "have", "been", "does", "about", "into", "more", "than"}]
            for kw in keywords:
                if kw in ctx_lower:
                    warnings.append(
                        f"DDD conflict: Non-goal '{ng}' keyword '{kw}' "
                        f"found in pipeline context — verify this isn't violating a non-goal"
                    )

    # Check 2: Failed patterns existence
    if "IMPROVEMENT.md" in ddd_docs:
        failed_patterns = _parse_failed_patterns(ddd_docs["IMPROVEMENT.md"])
        if not failed_patterns:
            warnings.append(
                "DDD note: IMPROVEMENT.md has no 'What Failed' entries — "
                "consider recording lessons from past work"
            )

    # Check 3: Missing DDD docs (not blocking, just informational)
    missing = [d for d in ("PRODUCT.md", "TECH.md", "IMPROVEMENT.md", "PROJECT.md")
               if d not in ddd_docs]
    if missing:
        warnings.append(
            f"DDD incomplete: Missing {', '.join(missing)} for project '{project}' — "
            f"pipeline runs at reduced intelligence (L0/L1 instead of L2)"
        )

    return {
        "warnings": warnings,
        "checksums": checksums,
        "non_goals": non_goals,
        "failed_patterns": failed_patterns,
    }


def check_ddd_staleness(project: str) -> dict[str, Any]:
    """Check if any completed pipeline runs are stale (DDD docs changed since evaluation).

    Scans all completed runs in Projects/<project>/.artifacts/runs/, reads their
    stored ``ddd_checksums`` field, and compares against current DDD doc checksums.

    Returns:
        {
            "current_checksums": {"PRODUCT.md": "abc...", ...},
            "stale_runs": [
                {"run_id": "run_xxx", "stale_docs": ["PRODUCT.md"],
                 "run_checksums": {...}, "status": "completed"}
            ],
            "fresh_runs": ["run_yyy"],
            "untracked_runs": ["run_zzz"]  # runs without stored checksums
        }
    """
    ws = _get_workspace()
    runs_dir = ws / "Projects" / project / ".artifacts" / "runs"

    # Get current checksums
    current = check_ddd_consistency(project)
    current_checksums = current["checksums"]

    result: dict[str, Any] = {
        "current_checksums": current_checksums,
        "stale_runs": [],
        "fresh_runs": [],
        "untracked_runs": [],
    }

    if not runs_dir.exists():
        return result

    for run_dir in sorted(runs_dir.iterdir()):
        run_file = run_dir / "run.json"
        if not run_file.exists():
            continue

        try:
            run = json.loads(run_file.read_text())
        except (json.JSONDecodeError, OSError):
            continue

        run_id = run.get("id", run_dir.name)
        run_status = run.get("status", "unknown")

        # Only check completed or delivered runs (active runs will re-evaluate anyway)
        if run_status not in ("completed", "delivered"):
            continue

        stored_checksums = run.get("ddd_checksums")
        if not stored_checksums:
            result["untracked_runs"].append(run_id)
            continue

        # Compare each doc
        stale_docs = []
        for doc_name, current_hash in current_checksums.items():
            stored_hash = stored_checksums.get(doc_name)
            if stored_hash and stored_hash != current_hash:
                stale_docs.append(doc_name)
            elif not stored_hash and current_hash:
                # Doc was added after the run
                stale_docs.append(doc_name)

        if stale_docs:
            result["stale_runs"].append({
                "run_id": run_id,
                "stale_docs": stale_docs,
                "run_checksums": stored_checksums,
                "status": run_status,
            })
        else:
            result["fresh_runs"].append(run_id)

    return result


def _extract_section(text: str, heading: str) -> str:
    """Extract a markdown section by heading (case-insensitive). Returns empty string if not found."""
    lines = text.splitlines()
    capturing = False
    result: list[str] = []
    for line in lines:
        stripped = line.strip()
        if re.match(rf"^##\s+{re.escape(heading)}", stripped, re.IGNORECASE):
            capturing = True
            continue
        if capturing and stripped.startswith("## "):
            break
        if capturing:
            result.append(line)
    return "\n".join(result)


# ---------------------------------------------------------------------------
# Depth validation (L2) — field values indicate real work
# ---------------------------------------------------------------------------

def _check_depth(stage: str, artifact_data: dict, profile: str) -> list[str]:
    """Layer 2: validate field values, not just existence.

    Catches hollow artifacts where fields exist but content indicates
    the quality gate was not actually executed.
    """
    errors: list[str] = []

    if stage == "review":
        # runtime_patterns: must be dict with checked > 0 and patterns list
        rp = artifact_data.get("runtime_patterns")
        if rp is not None and not isinstance(rp, dict):
            errors.append(
                f"Depth: runtime_patterns must be a dict, got {type(rp).__name__}"
            )
        elif isinstance(rp, dict):
            checked = rp.get("checked", 0)
            if checked == 0:
                errors.append(
                    "Depth: runtime_patterns.checked == 0 — "
                    "RP1-RP29 checklist was not executed"
                )
            elif not rp.get("patterns"):
                errors.append(
                    f"Depth: runtime_patterns.checked={checked} but patterns list is empty — "
                    f"include per-pattern results (even 'N/A' is valid)"
                )

    if stage == "deliver":
        # adversarial_review: MUST exist as dict with profile_tier for full/bugfix
        ar = artifact_data.get("adversarial_review")
        if ar is None:
            # Absent field = adversarial review was never run
            if profile in ("full", "bugfix", "standard", ""):
                errors.append(
                    "Depth: adversarial_review field MISSING from deliver artifact — "
                    "adversarial sub-agent was never spawned. This is MANDATORY for "
                    "full/bugfix profiles. Spawn specialist sub-agents before publishing."
                )
        elif not isinstance(ar, dict):
            errors.append(
                f"Depth: adversarial_review must be a dict, got {type(ar).__name__}"
            )
        elif isinstance(ar, dict):
            tier = ar.get("profile_tier")
            if not tier:
                errors.append(
                    "Depth: adversarial_review.profile_tier missing — "
                    "was the sub-agent actually spawned?"
                )
            elif tier in ("skipped", "lite") and profile in ("full", "bugfix"):
                errors.append(
                    f"Depth: adversarial_review.profile_tier='{tier}' but profile='{profile}' "
                    f"requires full adversarial review (independent sub-agent). "
                    f"Only docs/trivial/research profiles allow lite/skipped."
                )
            elif tier != "skipped" and "findings" not in ar:
                errors.append(
                    "Depth: adversarial_review ran but has no 'findings' field"
                )
            elif tier != "skipped":
                # HIGH findings must be resolved
                high_unresolved = [
                    f for f in ar.get("findings", [])
                    if isinstance(f, dict) and f.get("severity") == "HIGH" and not f.get("resolved")
                ]
                if high_unresolved:
                    errors.append(
                        f"Depth: adversarial_review has {len(high_unresolved)} "
                        f"unresolved HIGH finding(s) — fix before delivery"
                    )

        # completion_audit: MUST exist for full/bugfix profiles
        ca = artifact_data.get("completion_audit")
        if ca is None and profile in ("full", "bugfix", "standard", ""):
            errors.append(
                "Depth: completion_audit field MISSING from deliver artifact — "
                "was the Completion Audit (AC → evidence verification) actually run? "
                "This is MANDATORY. Run the audit before publishing."
            )
        elif isinstance(ca, dict):
            if "all_green" not in ca:
                errors.append(
                    "Depth: completion_audit.all_green missing — "
                    "was the audit actually run?"
                )
            elif not ca["all_green"]:
                fixable = ca.get("gaps", 0) - ca.get("unfixable_gaps", 0)
                if fixable > 0:
                    errors.append(
                        f"Depth: completion_audit has {fixable} fixable gap(s) — "
                        f"fix them or mark as unfixable_gaps before delivery"
                    )
            # Rule 16: unfixable_gaps must have justification
            unfixable = ca.get("unfixable_gaps", 0)
            if unfixable > 0 and not ca.get("unfixable_justification"):
                errors.append(
                    f"Depth: completion_audit has {unfixable} unfixable_gaps "
                    f"but no 'unfixable_justification' — explain why each gap "
                    f"cannot be fixed (Rule 17: no premature completion)"
                )

        # confidence_score: must be dict from script, not hand-written number
        cs = artifact_data.get("confidence_score")
        if isinstance(cs, (int, float)):
            errors.append(
                "Depth: confidence_score is a bare number — "
                "must run confidence_score.py which returns "
                "{score, breakdown, penalties}. Hand-written scores are not accepted."
            )
        elif isinstance(cs, dict):
            if "breakdown" not in cs or "penalties" not in cs:
                errors.append(
                    "Depth: confidence_score missing 'breakdown' or 'penalties' — "
                    "run confidence_score.py to generate a real score"
                )

        # --- Rule 18: Adversarial findings must be specific ---
        # Reuses `ar` from this deliver block (F1 fix: no duplicate fetch)
        if isinstance(ar, dict) and ar.get("profile_tier") not in ("skipped", None):
            findings = ar.get("findings", [])
            if isinstance(findings, list) and len(findings) > 0:
                vague_count = 0
                for f in findings:
                    if not isinstance(f, dict):
                        continue
                    # A specific finding should reference a file path or function.
                    # Check multiple common field names for the description text.
                    desc = str(
                        f.get("finding", "")
                        or f.get("issue", "")
                        or f.get("desc", "")
                        or f.get("description", "")
                    )
                    has_file_ref = ("." in desc and ("/" in desc or ".py" in desc or ".ts" in desc or ".js" in desc))
                    has_func_ref = ("()" in desc or "def " in desc or "function " in desc or "line" in desc.lower())
                    # Not vague if: has file reference OR function reference OR is detailed (>= 50 chars)
                    if not has_file_ref and not has_func_ref and len(desc) < 50:
                        vague_count += 1
                if vague_count > 0 and vague_count >= len(findings) * 0.5:
                    errors.append(
                        f"Depth: {vague_count}/{len(findings)} adversarial findings are vague "
                        f"(no file path, function name, or line reference). "
                        f"Rule 18: findings must include file, what's wrong, and concrete fix."
                    )

    if stage == "build":
        # tdd: must include green_pass
        tdd = artifact_data.get("tdd")
        if isinstance(tdd, dict) and "green_pass" not in tdd:
            errors.append(
                "Depth: tdd.green_pass missing — was the RED→GREEN cycle completed?"
            )

    return errors


def _check_semantic_depth(
    stage: str, artifact_data: dict, run: dict, project: str, run_id: str,
) -> list[str]:
    """Layer 2.5: semantic heuristic checks — WARN level.

    Catches artifacts that are structurally valid but content-lazy.
    Returns a list of warning strings (never errors/blocks).

    Three checks:
      1. Completion audit evidence quality (deliver) — >=70% cite file/test
      2. RP patterns evidence quality (review) — >=50% non-N/A have >10 char evidence
      3. Confidence penalty consistency (deliver) — empty penalties when issues exist
    """
    warnings: list[str] = []

    # --- Check 1: Completion audit evidence quality (deliver stage) ---
    if stage == "deliver":
        ca = artifact_data.get("completion_audit", {})
        checklist = ca.get("checklist", []) if isinstance(ca, dict) else []
        if isinstance(checklist, list) and len(checklist) >= 2:
            substantive = 0
            for entry in checklist:
                if not isinstance(entry, dict):
                    continue
                ev = str(entry.get("evidence", ""))
                has_file = any(ext in ev for ext in _EVIDENCE_FILE_EXTS)
                has_test = "test_" in ev or "test(" in ev or "::test" in ev
                has_line = "line " in ev.lower() or "line:" in ev.lower()
                if has_file or has_test or has_line:
                    substantive += 1
            ratio = substantive / len(checklist)  # PE-8: len >= 2 guaranteed
            if ratio < 0.7:
                warnings.append(
                    f"Semantic: completion_audit evidence quality — "
                    f"{substantive}/{len(checklist)} entries ({ratio:.0%}) cite a file path "
                    f"or test name. Rule 16 requires verifiable evidence, not assertions "
                    f"like 'implemented' or 'verified'. Aim for >=70%."
                )

    # --- Check 2: RP patterns evidence quality (review stage) ---
    if stage == "review":
        rp = artifact_data.get("runtime_patterns", {})
        if isinstance(rp, dict):
            patterns = rp.get("patterns", [])
            if isinstance(patterns, list):
                non_na = [
                    p for p in patterns
                    if isinstance(p, dict) and str(p.get("result", "")).upper() != "N/A"
                ]
                if len(non_na) >= 2:
                    with_evidence = sum(
                        1 for p in non_na
                        if isinstance(p.get("evidence"), str) and len(p["evidence"]) > 10
                    )
                    ratio = with_evidence / len(non_na)
                    if ratio < 0.5:
                        warnings.append(
                            f"Semantic: runtime_patterns evidence quality — "
                            f"{with_evidence}/{len(non_na)} non-N/A patterns have "
                            f"evidence text >10 chars. 'PASS' without context is not "
                            f"verifiable. Add what was checked (e.g., 'No subprocess in "
                            f"changeset' or 'Error constant matches line 85'). Aim for >=50%."
                        )

    # --- Check 3: Confidence penalty consistency (deliver stage) ---
    # Skip if confidence_score isn't a dict — _check_depth already BLOCKs on bare numbers (PE-5)
    if stage == "deliver" and isinstance(artifact_data.get("confidence_score"), dict):
        cs = artifact_data["confidence_score"]
        penalties = cs.get("penalties", [])

        # Check if review had findings
        review_findings = 0
        test_failures = 0
        for s in run.get("stages", []):
            s_name = s.get("stage", s.get("name", ""))
            s_art_id = s.get("artifact_id")
            if not s_art_id:
                continue
            if s_name == "review":
                rev_data = _load_artifact_data(project, run_id, s_art_id)
                if rev_data:
                    review_findings = rev_data.get("findings_count", 0)
                    if not isinstance(review_findings, int):
                        review_findings = 0
            elif s_name == "test":
                test_data = _load_artifact_data(project, run_id, s_art_id)
                if test_data:
                    test_failures = test_data.get("failed", 0)
                    if not isinstance(test_failures, int):
                        test_failures = 0

        has_issues = review_findings > 0 or test_failures > 0
        if has_issues and isinstance(penalties, list) and len(penalties) == 0:
            warnings.append(
                f"Semantic: confidence_score penalties empty but issues exist — "
                f"review had {review_findings} finding(s), tests had {test_failures} "
                f"failure(s). A score with zero penalties despite known issues suggests "
                f"the scoring didn't account for them. Even if all were fixed, record "
                f"the penalty + resolution."
            )

    return warnings


# ---------------------------------------------------------------------------
# Check 12: Anti-rationalization gate — skips require structured justification
# ---------------------------------------------------------------------------

def _check_skip_justification(stage_record: dict) -> list[str]:
    """Check 12: Anti-rationalization — skips require structured justification.

    When a stage is skipped, the agent must fill a 4-field justification:
      - step_skipped: what is being skipped
      - reason: why the agent thinks it's safe
      - evidence_skip_safe: concrete evidence supporting the skip
      - counter_argument_check: must be null/empty to proceed

    THE KEY RULE: if counter_argument_check is non-empty, the skip is BLOCKED.
    This prevents the agent from rationalizing past its own corrections.

    Returns list of BLOCK-level error strings.
    """
    errors: list[str] = []
    status = stage_record.get("status")
    if status != "skipped":
        return errors

    # Allow skip_reason alone (legacy/simple skips like "profile does not include")
    skip_reason = stage_record.get("skip_reason", "")
    justification = stage_record.get("skip_justification")

    # If no justification object but has skip_reason, that's the legacy path — allow
    # F7: minimum 15 chars to prevent trivial bypass (".", "ok", "skip")
    if not justification:
        if not skip_reason or not skip_reason.strip():
            errors.append(
                "BLOCK: Stage skipped without skip_reason or skip_justification. "
                "Provide at minimum a skip_reason, or a full skip_justification "
                "with step_skipped, reason, evidence_skip_safe, counter_argument_check."
            )
        elif len(skip_reason.strip()) < 15:
            errors.append(
                "BLOCK: skip_reason too short (minimum 15 characters). "
                "Provide meaningful justification for skipping this stage."
            )
        return errors

    # Full justification provided — validate structure
    if not isinstance(justification, dict):
        errors.append("BLOCK: skip_justification must be a dict with 4 fields")
        return errors

    required_fields = ["step_skipped", "reason", "evidence_skip_safe"]
    for field in required_fields:
        val = justification.get(field)
        if not val or (isinstance(val, str) and not val.strip()):
            errors.append(f"BLOCK: skip_justification.{field} is empty or missing")

    # THE KEY RULE: if counter-argument exists, skip is blocked
    # F1 fix: coerce to string to prevent bypass via list/dict/bool types
    counter = justification.get("counter_argument_check")
    if counter is not None:
        if not isinstance(counter, str):
            # Non-string truthy value (list, dict, int, True) = bypass attempt
            errors.append(
                "BLOCK: skip_justification.counter_argument_check must be a string "
                f"or null, got {type(counter).__name__}. Non-string types are rejected."
            )
        elif counter.strip():
            errors.append(
                f"BLOCK: Anti-rationalization triggered — counter-argument exists: "
                f"'{counter[:120]}'. Cannot skip when a valid counter-argument is present. "
                f"Either address the counter-argument or execute the stage."
            )

    return errors


# ---------------------------------------------------------------------------
# Check 13: Output routing — stages must consume declared inputs
# ---------------------------------------------------------------------------

def _check_output_routing(
    stage: str, stage_record: dict, run: dict, project: str
) -> tuple[list[str], list[str]]:
    """Check 13: Explicit output routing enforcement.

    Verifies that a stage:
      - Consumed all declared upstream artifacts (BLOCK if not)
      - Produced the declared output artifact type (WARN — Check 2 handles existence)

    The consumed_artifacts field in stage records tracks what was actually read.
    Format: [{"type": "evaluation", "id": "art_xxx"}, ...]

    Returns (errors, warnings) tuple.
    """
    errors: list[str] = []
    warnings: list[str] = []

    routing = STAGE_ROUTING.get(stage)
    if not routing:
        return errors, warnings

    # Skip routing check for skipped stages
    if stage_record.get("status") == "skipped":
        return errors, warnings

    # --- Consume check ---
    consumed = stage_record.get("consumed_artifacts")
    has_consumed_field = consumed is not None
    if not isinstance(consumed, list):
        consumed = []

    consumed_types = {
        c.get("type") for c in consumed if isinstance(c, dict) and c.get("type")
    }

    for required_type in routing.get("consumes", []):
        if required_type not in consumed_types:
            # Check if a prior stage even produced this type
            stages_list = run.get("stages", [])
            prior_produced = False
            for s in stages_list:
                if s.get("stage", s.get("name")) == stage:
                    break
                s_routing = STAGE_ROUTING.get(s.get("stage", s.get("name", "")), {})
                if required_type in s_routing.get("produces", []):
                    if s.get("status") in ("completed", "done"):
                        prior_produced = True
                        break

            if prior_produced and has_consumed_field:
                # consumed_artifacts field exists but doesn't include this type → BLOCK
                errors.append(
                    f"BLOCK: Stage '{stage}' must consume '{required_type}' artifact "
                    f"but didn't reference it in consumed_artifacts. "
                    f"Run `discover --types {required_type} --full` and record consumption."
                )
            elif prior_produced:
                # consumed_artifacts field missing entirely → WARN (backward compat)
                warnings.append(
                    f"WARN: Stage '{stage}' should consume '{required_type}' artifact. "
                    f"Add consumed_artifacts field to stage record for routing enforcement."
                )
            else:
                # Upstream didn't produce it — warn (might be skipped stage)
                warnings.append(
                    f"WARN: Stage '{stage}' should consume '{required_type}' "
                    f"but no prior stage produced it (may have been skipped)."
                )

    return errors, warnings


# ---------------------------------------------------------------------------
# Artifact freshness — content-hash staleness detection
# ---------------------------------------------------------------------------

def check_artifact_freshness(
    artifact_meta: dict, project: str
) -> dict[str, Any]:
    """Evaluate whether an artifact's conclusions are still valid.

    Checks:
      1. DDD dependency drift — checksums at creation vs current
      2. TTL advisory — age exceeds configured threshold

    Args:
        artifact_meta: Artifact metadata dict (from manifest or stage record)
                       Must include 'created_at'. Optional: 'freshness' with
                       'depends_on' and 'ttl_advisory_hours'.
        project: Project name for DDD doc lookup.

    Returns:
        {"fresh": bool, "stale_reason": str | None, "age_hours": float}
    """
    from datetime import datetime, timezone

    created_str = artifact_meta.get("created_at", "")
    if not created_str:
        return {"fresh": True, "stale_reason": None, "age_hours": 0}

    try:
        # Handle both Z suffix and +00:00 formats
        created_str_clean = created_str.replace("Z", "+00:00")
        created = datetime.fromisoformat(created_str_clean)
        if created.tzinfo is None:
            created = created.replace(tzinfo=timezone.utc)
        now = datetime.now(timezone.utc)
        age_hours = (now - created).total_seconds() / 3600
    except (ValueError, TypeError):
        return {"fresh": True, "stale_reason": None, "age_hours": 0}

    freshness = artifact_meta.get("freshness", {})
    if not isinstance(freshness, dict):
        freshness = {}

    depends_on = freshness.get("depends_on", {})
    if not isinstance(depends_on, dict):
        depends_on = {}

    # Check 1: DDD dependency drift
    ddd_checksums = depends_on.get("ddd_checksums", {})
    if ddd_checksums:
        ws = _get_workspace()
        project_dir = ws / "Projects" / project
        for doc_name, expected_hash in ddd_checksums.items():
            doc_path = project_dir / doc_name
            if doc_path.exists():
                try:
                    content = doc_path.read_text()
                    current_hash = _compute_doc_checksum(content)
                    if current_hash != expected_hash:
                        return {
                            "fresh": False,
                            "stale_reason": (
                                f"DDD doc {doc_name} changed since artifact was created"
                            ),
                            "age_hours": age_hours,
                        }
                except OSError:
                    pass
            else:
                # F6: Document was deleted — stronger staleness signal
                return {
                    "fresh": False,
                    "stale_reason": (
                        f"DDD doc {doc_name} was deleted since artifact was created"
                    ),
                    "age_hours": age_hours,
                }

    # Check 2: TTL advisory (soft)
    ttl = freshness.get("ttl_advisory_hours", _DEFAULT_TTL_HOURS)
    if not isinstance(ttl, (int, float)):
        ttl = _DEFAULT_TTL_HOURS
    if age_hours > ttl:
        return {
            "fresh": False,
            "stale_reason": f"Age {age_hours:.0f}h exceeds advisory TTL {ttl}h",
            "age_hours": age_hours,
        }

    return {"fresh": True, "stale_reason": None, "age_hours": age_hours}


# ---------------------------------------------------------------------------
# Core validation
# ---------------------------------------------------------------------------

def validate(project: str, run_id: str, stage: str) -> dict[str, Any]:
    """Validate a pipeline stage against structural + semantic invariants.

    Checks 1-8: structural invariants (BLOCK on violations)
    Check 9: depth validation L2 (BLOCK on hollow artifacts)
    Check 10: confidence/push-ready gate L3 (BLOCK on not-push-ready)
    Check 11: semantic depth L2.5 (WARN on content-lazy artifacts)
    Check 12: anti-rationalization gate (BLOCK on skips with counter-arguments)
    Check 13: output routing (BLOCK on missing consumed_artifacts) + freshness (WARN)

    Returns a result dict with:
        valid: bool — False if any BLOCK errors
        stage: str — the validated stage
        errors: list[str] — BLOCK-level violations
        warnings: list[str] — informational issues + semantic heuristics
        checks_passed: int
        checks_total: int
    """
    errors: list[str] = []
    warnings: list[str] = []
    checks_total = 8
    checks_passed = 0

    # Load run state
    run = _load_run(project, run_id)
    if run is None:
        return {
            "valid": False,
            "stage": stage,
            "errors": [f"Pipeline run {run_id} not found for project {project}"],
            "warnings": [],
            "checks_passed": 0,
            "checks_total": checks_total,
        }

    profile = run.get("profile") or "full"
    stages_list = run.get("stages", [])

    # Find the stage record for the stage being validated
    stage_record = None
    for s in stages_list:
        if s.get("stage", s.get("name")) == stage:
            stage_record = s

    if stage_record is None:
        return {
            "valid": False,
            "stage": stage,
            "errors": [f"No stage record found for '{stage}' in run {run_id}"],
            "warnings": [],
            "checks_passed": 0,
            "checks_total": checks_total,
        }

    # Pre-load artifact data for depth/gate checks (L2/L3)
    _art_id = stage_record.get("artifact_id")
    artifact_data: dict[str, Any] | None = None
    if _art_id and stage not in NO_ARTIFACT_STAGES:
        artifact_data = _load_artifact_data(project, run_id, _art_id)
        # For deliver stage: artifact MUST be loadable for adversarial review validation.
        # A deliver artifact_id without loadable data means publish happened but manifest
        # is missing/corrupt — this is a BLOCK because we can't verify quality gates.
        if artifact_data is None and stage == "deliver":
            errors.append(
                f"Artifact {_art_id} for 'deliver' could not be loaded — "
                f"file missing or corrupt"
            )

    # --- Check 1: Stage order ---
    if _check_stage_order(stage, profile, stages_list):
        checks_passed += 1
    else:
        profile_stages = get_profile_stages(profile)
        expected_idx = profile_stages.index(stage) if stage in profile_stages else -1
        if expected_idx > 0:
            expected_prev = profile_stages[expected_idx - 1]
            errors.append(
                f"Stage order violation: '{stage}' requires '{expected_prev}' "
                f"to be completed first (profile: {profile})"
            )
        else:
            errors.append(f"Stage order violation: '{stage}' position invalid in profile '{profile}'")

    # --- Check 2: Artifact exists ---
    if _check_artifact_exists(stage, stage_record):
        checks_passed += 1
    else:
        if stage in NO_ARTIFACT_STAGES:
            checks_passed += 1  # Never required
        elif stage in ARTIFACT_OPTIONAL_FOR_PROFILES.get(profile or "", set()):
            # Optional for this profile — warn, don't block
            warnings.append(
                f"No artifact for '{stage}' (optional in {profile} profile)"
            )
            checks_passed += 1
        else:
            errors.append(
                f"No artifact published for '{stage}' — "
                f"artifact_id is missing or empty in stage record"
            )

    # --- Check 3: Artifact schema ---
    schema_result = _check_artifact_schema(stage, stage_record, project, run_id)
    if schema_result["passed"]:
        checks_passed += 1
    errors.extend(schema_result.get("errors", []))
    warnings.extend(schema_result.get("warnings", []))

    # --- Check 4: Decision logged (WARN only — doesn't block) ---
    if _check_decision_logged(stage, stage_record):
        checks_passed += 1
    else:
        checks_passed += 1  # Warnings don't reduce checks_passed
        if stage not in DECISION_OPTIONAL_STAGES:
            warnings.append(
                f"No decisions classified for '{stage}' — "
                f"classify at least one decision (mechanical/taste/judgment)"
            )

    # --- Check 5: Budget recorded (WARN only — doesn't block) ---
    if _check_budget_recorded(stage_record):
        checks_passed += 1
    else:
        checks_passed += 1  # Warnings don't reduce checks_passed
        warnings.append(
            f"token_cost is 0 for '{stage}' — "
            f"estimate the token cost for budget calibration"
        )

    # --- Check 6: Profile respected ---
    if _check_profile_respected(stage, profile):
        checks_passed += 1
    else:
        errors.append(
            f"Profile violation: '{stage}' is not in the '{profile}' profile. "
            f"Expected stages: {get_profile_stages(profile)}"
        )

    # --- Check 7: DDD cross-document consistency (WARN only) ---
    # Runs on evaluate stage — that's when DDD docs are first consulted.
    # On other stages, auto-pass (DDD was already validated at evaluate).
    if stage == "evaluate":
        # Build context text from the evaluation artifact for cross-check
        # Use pre-loaded artifact_data (don't re-load — F1/F2 fix)
        context_text = None
        if artifact_data:
            parts = [
                str(artifact_data.get("recommendation", "")),
                str(artifact_data.get("scope", "")),
                str(artifact_data.get("summary", "")),
                str(artifact_data.get("approach", "")),
            ]
            context_text = " ".join(parts)

        ddd_result = check_ddd_consistency(project, context_text)
        warnings.extend(ddd_result["warnings"])

        # Staleness check: warn if DDD docs changed since last completed run
        staleness = check_ddd_staleness(project)
        if staleness["stale_runs"]:
            latest_stale = staleness["stale_runs"][-1]  # most recent
            changed_docs = ", ".join(latest_stale["stale_docs"])
            warnings.append(
                f"DDD staleness: {changed_docs} changed since last pipeline run "
                f"({latest_stale['run_id']}). Prior evaluations may need review."
            )

        checks_passed += 1  # WARN only — never blocks
    else:
        checks_passed += 1  # Auto-pass for non-evaluate stages

    # --- Check 8: Smoke tests executed (WARN, build stage only) ---
    # When build touches >1 file, smoke tests must exercise new code paths
    # with real objects (not mocks) to catch AttributeError/NameError.
    # This check prevented 2 HIGH findings in the RecallEngine activation
    # where MagicMock masked a missing attribute on SessionUnit.
    if stage == "build":
        smoke_ok = True
        # Use pre-loaded artifact_data (F1/F2 fix — don't re-load)
        if artifact_data:
            tdd = artifact_data.get("tdd", {})
            files_changed = artifact_data.get("files_changed", [])
            code_files = [f for f in files_changed
                          if any(f.endswith(ext) for ext in _CODE_EXTS)]
            smoke_count = tdd.get("smoke_tests", 0) if isinstance(tdd, dict) else 0
            if len(code_files) > 1 and smoke_count == 0:
                smoke_ok = False
                errors.append(
                    f"SMOKE step skipped: build touched {len(code_files)} code files "
                    f"but smoke_tests=0 — runtime crashes (AttributeError, NameError) "
                    f"may be hidden by mocks. Run smoke tests with real objects "
                    f"before advancing to REVIEW."
                )
        if smoke_ok:
            checks_passed += 1
    elif stage == "review":
        # Check 8b: Integration trace must be present in review artifact
        # Use pre-loaded artifact_data (F1/F2 fix)
        trace_ok = True
        if artifact_data:
            trace = artifact_data.get("integration_trace", {})
            checked = trace.get("checked", 0) if isinstance(trace, dict) else 0
            if checked == 0:
                trace_ok = False
                errors.append(
                    "Integration trace missing: review must include "
                    "'integration_trace' with checked > 0. Verify every new "
                    "public symbol has a production caller, and every removed "
                    "call site doesn't orphan old code."
                )
        if trace_ok:
            checks_passed += 1

        # Check 8c: UX review when frontend files are in the changeset (WARN only)
        _FRONTEND_EXTS = (".tsx", ".jsx", ".css", ".html", ".svelte", ".vue")
        has_frontend = False
        build_data = None  # PE-1 fix: initialize before conditional assignment
        # Look for frontend files in the build stage's changeset artifact
        build_stage = next(
            (s for s in stages_list if s.get("stage", s.get("name")) == "build"),
            None,
        )
        if build_stage and build_stage.get("artifact_id"):
            build_data = _load_artifact_data(project, run_id, build_stage["artifact_id"])
            if build_data:
                has_frontend = any(
                    any(f.endswith(ext) for ext in _FRONTEND_EXTS)
                    for f in build_data.get("files_changed", [])
                )
        if has_frontend and artifact_data:
            ux = artifact_data.get("ux_review", {})
            triggered = ux.get("triggered", False) if isinstance(ux, dict) else False
            if not triggered:
                warnings.append(
                    "UX review not triggered: changeset includes frontend files "
                    "but review artifact has no 'ux_review' section. Run the 5-point "
                    "UX checklist (discoverability, feedback, behavioral contracts, "
                    "escape/click-outside, scroll tracking)."
                )
        # Check 8d: Review completeness — large changesets with zero findings are suspicious
        # Reuse build_data from 8c (F2 fix — don't re-load)
        if build_data:
                tdd = build_data.get("tdd", {})
                tests_gen = tdd.get("tests_generated", 0) if isinstance(tdd, dict) else 0
                code_files = [
                    f for f in build_data.get("files_changed", [])
                    if any(f.endswith(ext) for ext in _CODE_EXTS)
                ]
                is_large_changeset = len(code_files) > 3 or tests_gen > 10

                if is_large_changeset and artifact_data:
                        findings_count = artifact_data.get("findings_count", -1)
                        if findings_count == -1:
                            # No findings_count field at all — review artifact is incomplete
                            errors.append(
                                f"Review completeness: build touched {len(code_files)} code files "
                                f"with {tests_gen} tests, but review artifact has no "
                                f"'findings_count' field. A real review must report findings "
                                f"(even if 0) with justification."
                            )
                        elif findings_count == 0:
                            # 0 findings on a large changeset — suspicious but not blocking
                            warnings.append(
                                f"Review reported 0 findings on {len(code_files)} code files / "
                                f"{tests_gen} tests. Verify this is genuine — large changesets "
                                f"with zero findings often indicate a skipped review."
                            )
                elif is_large_changeset and not _art_id:  # PE-2 fix: was `artifact_id` (NameError)
                    errors.append(
                        f"Review completeness: build touched {len(code_files)} code files "
                        f"but REVIEW stage has no artifact_id. The review was skipped entirely."
                    )
    else:
        checks_passed += 1  # Auto-pass for other stages

    # --- Check 9: Depth validation (L2) — field values indicate real work ---
    # Only runs when artifact data is available (L0/L1 catch missing data)
    if artifact_data and stage in ("review", "deliver", "build"):
        depth_errors = _check_depth(stage, artifact_data, profile)
        errors.extend(depth_errors)
        if not depth_errors:
            checks_passed += 1
        checks_total += 1

    # --- Check 10: Push-Ready gate (L3) — binary verdict ---
    # V2: reads `quality.push_ready` (boolean) or infers from quality fields.
    # V1 compat: reads `confidence_score.score < 7`.
    if stage == "deliver" and artifact_data:
        quality = artifact_data.get("quality", {})
        conf = artifact_data.get("confidence_score", {})
        has_override = run.get("human_override", False)

        if isinstance(quality, dict) and "push_ready" in quality:
            # V2 path: explicit binary gate
            if not quality["push_ready"]:
                blockers = quality.get("blockers", [])
                if not has_override:
                    errors.append(
                        f"Push-ready gate: NOT-PUSH-READY. "
                        f"Blockers: {blockers}"
                    )
                else:
                    warnings.append(
                        f"Push-ready gate: NOT-PUSH-READY — "
                        f"OVERRIDDEN by human_override flag."
                    )
        elif isinstance(quality, dict) and quality.get("tests_pass"):
            # V2 inferred: quality has tests_pass=true + regressions=0 → push-ready
            # This handles artifacts that provide quality evidence without explicit push_ready
            regressions = quality.get("regressions", 0)
            if regressions > 0 and not has_override:
                errors.append(
                    f"Push-ready gate: quality.regressions={regressions} > 0."
                )
            # Otherwise: inferred push-ready (tests pass, no regressions)
        elif isinstance(conf, dict) and conf.get("score") is not None:
            # V1 compat: numeric confidence score
            score = conf.get("score", 0)
            if score < 7 and not has_override:
                errors.append(
                    f"Confidence gate: score={score}/12 (< 7). "
                    f"Fix penalties or add human_override to run.json."
                )
            elif score < 7 and has_override:
                warnings.append(
                    f"Confidence gate: score={score}/12 (< 7) — "
                    f"OVERRIDDEN by human_override flag."
                )
        # No quality and no confidence_score → skip gate (depth checks are sufficient)

        checks_total += 1
        if not any("push-ready gate" in e.lower() or "confidence gate" in e.lower() for e in errors):
            checks_passed += 1

    # --- Check 11: Semantic depth (L2.5) — content quality heuristics (WARN) ---
    if artifact_data and stage in ("review", "deliver"):
        sem_warnings = _check_semantic_depth(stage, artifact_data, run, project, run_id)
        warnings.extend(sem_warnings)

    # --- Check 12: Anti-rationalization gate (skipped stages) ---
    if stage_record.get("status") == "skipped":
        checks_total += 1
        skip_errors = _check_skip_justification(stage_record)
        errors.extend(skip_errors)
        if not skip_errors:
            checks_passed += 1

    # --- Check 13: Output routing — consume/produce enforcement ---
    if stage in STAGE_ROUTING and stage_record.get("status") != "skipped":
        checks_total += 1
        routing_errors, routing_warnings = _check_output_routing(
            stage, stage_record, run, project
        )
        errors.extend(routing_errors)
        warnings.extend(routing_warnings)
        if not routing_errors:
            checks_passed += 1

        # Freshness sub-check: warn on stale consumed artifacts
        # F3 fix: look up full artifact metadata from manifest (consumed_artifacts
        # entries typically only have type+id, not created_at/freshness).
        consumed = stage_record.get("consumed_artifacts", [])
        if isinstance(consumed, list):
            for c in consumed:
                if not isinstance(c, dict):
                    continue
                art_id = c.get("id")
                # Try to get full metadata from manifest for richer freshness check
                art_meta = c  # fallback to inline entry
                if art_id:
                    full_meta = _load_artifact_meta_from_manifest(project, art_id)
                    if full_meta:
                        art_meta = full_meta
                freshness_result = check_artifact_freshness(art_meta, project)
                if not freshness_result["fresh"]:
                    warnings.append(
                        f"STALE: Stage '{stage}' consuming stale artifact "
                        f"'{c.get('id', '?')}' (type: {c.get('type', '?')}, "
                        f"age: {freshness_result['age_hours']:.0f}h, "
                        f"reason: {freshness_result['stale_reason']}). "
                        f"Consider re-running the producing stage."
                    )

    return {
        "valid": len(errors) == 0,
        "stage": stage,
        "errors": errors,
        "warnings": warnings,
        "checks_passed": checks_passed,
        "checks_total": checks_total,
    }


# ---------------------------------------------------------------------------
# Individual checks
# ---------------------------------------------------------------------------

def _check_stage_order(stage: str, profile: str, stages_list: list[dict]) -> bool:
    """Check 1: Current stage follows the last completed stage per profile."""
    profile_stages = get_profile_stages(profile)

    if stage not in profile_stages:
        return False  # Not in profile at all — caught by check 6 too

    stage_idx = profile_stages.index(stage)

    if stage_idx == 0:
        return True  # First stage always valid

    # All prior stages in profile must be completed or skipped
    for i in range(stage_idx):
        prior_stage_name = profile_stages[i]
        prior_record = _find_stage_record(prior_stage_name, stages_list)
        if prior_record is None:
            return False  # Prior stage not even recorded
        if prior_record.get("status") not in ("completed", "done", "skipped"):
            return False  # Prior stage not done

    return True


def _check_artifact_exists(stage: str, stage_record: dict) -> bool:
    """Check 2: Stage published an artifact (reflect is exempt)."""
    if stage in NO_ARTIFACT_STAGES:
        return True  # No artifact required

    artifact_id = stage_record.get("artifact_id")
    return bool(artifact_id and artifact_id.strip())


def _check_artifact_schema(
    stage: str, stage_record: dict, project: str, run_id: str
) -> dict[str, Any]:
    """Check 3: Required/recommended fields present in artifact data.

    Returns {"passed": bool, "errors": [...], "warnings": [...]}.
    """
    result: dict[str, Any] = {"passed": True, "errors": [], "warnings": []}

    if stage not in STAGE_SCHEMAS:
        return result  # No schema defined (e.g., reflect)

    artifact_id = stage_record.get("artifact_id")
    if not artifact_id:
        # No artifact — this is caught by check 2, skip schema check
        return result

    schema = STAGE_SCHEMAS[stage]
    artifact_data = _load_artifact_data(project, run_id, artifact_id)

    if artifact_data is None:
        result["passed"] = False
        result["errors"].append(
            f"Artifact {artifact_id} for '{stage}' could not be loaded — "
            f"file missing or corrupt"
        )
        return result

    # Check required fields (BLOCK)
    for field in schema.get("required", []):
        if field not in artifact_data:
            result["passed"] = False
            result["errors"].append(
                f"Schema violation: '{stage}' artifact missing required field '{field}'"
            )

    # Check recommended fields (WARN)
    for field in schema.get("recommended", []):
        if field not in artifact_data:
            result["warnings"].append(
                f"Schema note: '{stage}' artifact missing recommended field '{field}'"
            )

    return result


def _check_decision_logged(stage: str, stage_record: dict) -> bool:
    """Check 4: At least 1 decision classified in the stage record."""
    if stage in DECISION_OPTIONAL_STAGES:
        return True  # reflect and deliver don't require decisions

    decisions = stage_record.get("decisions", [])
    return len(decisions) > 0


def _check_budget_recorded(stage_record: dict) -> bool:
    """Check 5: token_cost > 0 in the stage record."""
    token_cost = stage_record.get("token_cost", 0)
    return token_cost > 0


def _check_profile_respected(stage: str, profile: str) -> bool:
    """Check 6: Stage is in the selected pipeline profile."""
    profile_stages = get_profile_stages(profile)
    return stage in profile_stages


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

def _find_stage_record(stage_name: str, stages_list: list[dict]) -> dict | None:
    """Find the most recent record for a given stage name."""
    for s in reversed(stages_list):
        if s.get("stage", s.get("name")) == stage_name:
            return s
    return None


def _load_artifact_meta_from_manifest(project: str, artifact_id: str) -> dict | None:
    """Load artifact metadata (not data) from manifest by ID.

    Returns the manifest entry dict which includes created_at, freshness, etc.
    Returns None if not found.
    """
    ws = _get_workspace()
    manifest_file = ws / "Projects" / project / ".artifacts" / "manifest.json"
    if not manifest_file.exists():
        return None
    try:
        manifest = json.loads(manifest_file.read_text())
    except (json.JSONDecodeError, OSError):
        return None
    for entry in manifest.get("artifacts", []):
        if entry.get("id") == artifact_id:
            return entry
    return None


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Pipeline stage validator — structural enforcement"
    )
    sub = parser.add_subparsers(dest="command")

    # check command
    check_p = sub.add_parser("check", help="Validate a pipeline stage")
    check_p.add_argument("--project", required=True, help="Project name")
    check_p.add_argument("--run-id", required=True, help="Pipeline run ID")
    check_p.add_argument("--stage", required=True, help="Stage to validate")

    # summary command — validate all completed stages in a run
    summary_p = sub.add_parser("summary", help="Validate all stages in a pipeline run")
    summary_p.add_argument("--project", required=True, help="Project name")
    summary_p.add_argument("--run-id", required=True, help="Pipeline run ID")

    # ddd-check command — standalone DDD cross-document consistency check
    ddd_p = sub.add_parser("ddd-check", help="Check DDD document consistency for a project")
    ddd_p.add_argument("--project", required=True, help="Project name")
    ddd_p.add_argument("--context", default=None, help="Optional text to check against non-goals")

    # ddd-staleness command — check if pipeline runs are stale
    stale_p = sub.add_parser("ddd-staleness", help="Check which pipeline runs are stale (DDD docs changed)")
    stale_p.add_argument("--project", required=True, help="Project name")

    args = parser.parse_args()

    if args.command == "check":
        result = validate(args.project, args.run_id, args.stage)
        print(json.dumps(result, indent=2))
        sys.exit(0 if result["valid"] else 1)

    elif args.command == "summary":
        run = _load_run(args.project, args.run_id)
        if run is None:
            print(json.dumps({"error": f"Run {args.run_id} not found"}))
            sys.exit(1)

        all_results = []
        total_errors = 0
        total_warnings = 0

        for stage_rec in run.get("stages", []):
            stage_name = stage_rec.get("stage", stage_rec.get("name"))
            if stage_rec.get("status") in ("completed", "done", "running"):
                result = validate(args.project, args.run_id, stage_name)
                all_results.append(result)
                total_errors += len(result["errors"])
                total_warnings += len(result["warnings"])

        # Adversarial meta-monitoring — check review health across runs
        adversarial_health = {}
        try:
            from core.adversarial_meta import check_adversarial_health

            artifacts_dir = _get_artifacts_dir(args.project)
            if artifacts_dir:
                adversarial_health = check_adversarial_health(artifacts_dir)
                if adversarial_health.get("degradation_warning"):
                    total_warnings += 1
        except Exception:
            pass  # Non-blocking — meta-monitoring is advisory

        summary = {
            "run_id": args.run_id,
            "project": args.project,
            "valid": total_errors == 0,
            "stages_checked": len(all_results),
            "total_errors": total_errors,
            "total_warnings": total_warnings,
            "results": all_results,
            "adversarial_health": adversarial_health,
        }
        print(json.dumps(summary, indent=2))
        sys.exit(0 if total_errors == 0 else 1)

    elif args.command == "ddd-check":
        result = check_ddd_consistency(args.project, args.context)
        print(json.dumps(result, indent=2))
        sys.exit(0)  # Always exit 0 — warnings only

    elif args.command == "ddd-staleness":
        result = check_ddd_staleness(args.project)
        print(json.dumps(result, indent=2))
        # Exit 1 if stale runs found (useful for CI/scripting)
        sys.exit(1 if result["stale_runs"] else 0)

    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()
