Skill: su-ariel/epistemic-council (version 1.0.0)

# Epistemic Council Skill Use this skill for all Epistemic Council pipeline operations. ## Triggers "run council", "run pipeline", "substrate status", "council status", "health check", "validate claims", "check boundaries", "find gaps", "rechallenge" ## How to invoke Use the `exec` tool with the epistemic_council working directory: ``` exec: cd /root/.openclaw/workspace-epistemic-council-bot/epistemic_council && python epistemic_skill.py "run council" ``` Available commands (replace argument as needed): - `"run council"` — full pipeline run - `"substrate status"` — event counts + last run summary - `"health check"` — risk / integrity scan - `"validate claims"` — re-evaluate challenged-zone claims - `"check boundaries"` — domain boundary audit - `"find gaps"` — orphan + sparse coverage scan - `"rechallenge"` — adversarial re-challenge of top insight ## Workspace `/root/.openclaw/workspace-epistemic-council-bot/` ## Working directory for exec `/root/.openclaw/workspace-epistemic-council-bot/epistemic_council/`

path: adversarial.py
content: """
epistemic_council/adversarial.py

Adversarial validation agents that challenge insights to test robustness.

Three adversarial agent types:
1. Boundary Violation Agent - Checks if source claims respect domain boundaries
2. Null Hypothesis Agent - Tests if insight is emergent or already known
3. False Analogy Breaker - Generates counter-examples to test structural validity
"""

import requests
from typing import Dict, List, Any, Optional
from dataclasses import dataclass


@dataclass
class ChallengeResult:
    """Result of an adversarial challenge"""
    passed: bool
    confidence_adjustment: float
    reasoning: str
    details: Dict[str, Any]


class BoundaryViolationAgent:
    """Checks if source claims respect domain boundaries"""
    
    DOMAIN_BOUNDARIES = {
        'computer_science': {
            'forbidden_terms': [
                'evolve', 'evolution', 'evolutionary', 'natural selection',
                'organism', 'species', 'mutation', 'gene', 'biological',
                'ecology', 'predator', 'prey', 'fitness', 'offspring',
                'reproduce', 'reproduction', 'adapt', 'adaptation'
            ]
        },
        'biology': {
            'forbidden_terms': [
                'algorithm', 'algorithmic', 'complexity', 'computational',
                'O(n)', 'runtime', 'hash table', 'data structure',
                'memory allocation', 'compilation', 'syntax', 'debug',
                'code', 'program', 'software', 'hardware'
            ]
        }
    }
    
    def __init__(self, substrate):
        self.substrate = substrate
    
    def challenge(self, insight: Dict[str, Any]) -> ChallengeResult:
        """Check if source claims violate domain boundaries"""
        violations = []
        source_claim_ids = insight.get('content', {}).get('source_claim_ids', [])
        
        if not source_claim_ids:
            return ChallengeResult(
                passed=False, confidence_adjustment=0.0,
                reasoning="No source claims - cannot verify boundaries",
                details={'violations': []}
            )
        
        for claim_id in source_claim_ids:
            claim_event = self.substrate.get_event(claim_id)
            if not claim_event or claim_event.domain not in self.DOMAIN_BOUNDARIES:
                continue
            
            text = claim_event.content.get('text', '').lower()
            forbidden = self.DOMAIN_BOUNDARIES[claim_event.domain]['forbidden_terms']
            found_terms = [term for term in forbidden if term in text]
            
            if found_terms:
                violations.append({
                    'claim_id': claim_id,
                    'domain': claim_event.domain,
                    'text': claim_event.content.get('text', '')[:100],
                    'found_terms': found_terms[:3]
                })
        
        if len(violations) == 0:
            return ChallengeResult(
                passed=True, confidence_adjustment=1.0,
                reasoning="All source claims respect domain boundaries",
                details={'violations': []}
            )
        else:
            penalty = min(0.4 * len(violations), 0.8)
            return ChallengeResult(
                passed=False, confidence_adjustment=max(0.2, 1.0 - penalty),
                reasoning=f"Found {len(violations)} boundary violation(s)",
                details={'violations': violations}
            )


class NullHypothesisAgent:
    """Tests if insight adds value over single-domain reasoning"""
    
    def __init__(self, substrate, model_url: str = "http://localhost:11434"):
        self.substrate = substrate
        self.model_url = model_url
        self.model_name = "qwen2.5:14b"
    
    def challenge(self, insight: Dict[str, Any], query_text: str) -> ChallengeResult:
        """Ask each domain if they already know this insight"""
        insight_text = insight.get('content', {}).get('text', '')
        
        cs_knows = self._ask_domain('computer_science', insight_text, query_text)
        bio_knows = self._ask_domain('biology', insight_text, query_text)
        
        if cs_knows and bio_knows:
            return ChallengeResult(
                passed=False, confidence_adjustment=0.3,
                reasoning="Both domains already recognize this independently",
                details={'cs_knows': cs_knows, 'bio_knows': bio_knows, 'emergent': False}
            )
        elif cs_knows or bio_knows:
            return ChallengeResult(
                passed=True, confidence_adjustment=0.6,
                reasoning="Primarily known in one domain, but cross-domain adds value",
                details={'cs_knows': cs_knows, 'bio_knows': bio_knows, 'emergent': 'partial'}
            )
        else:
            return ChallengeResult(
                passed=True, confidence_adjustment=1.0,
                reasoning="Insight is emergent - not obvious from either domain alone",
                details={'cs_knows': False, 'bio_knows': False, 'emergent': True}
            )
    
    def _ask_domain(self, domain: str, insight_text: str, query_text: str) -> bool:
        """Ask if domain already knows this insight"""
        prompt = f"""You are a {domain} expert answering: "{query_text}"

Someone claims: "{insight_text}"

Within {domain} alone, is this obvious/well-known?

Answer YES only if: textbook knowledge, any expert would recognize it, requires NO cross-domain knowledge.
Answer NO if: requires cross-domain knowledge, novel connection, synthesizes multiple fields.

Your answer (YES or NO only):"""

        try:
            response = requests.post(
                f"{self.model_url}/api/generate",
                json={"model": self.model_name, "prompt": prompt, "stream": False,
                      "options": {"temperature": 0.3, "num_predict": 50}},
                timeout=30
            )
            if response.status_code == 200:
                text = response.json().get('response', '').lower()
                return 'yes' in text and 'no' not in text
            return False
        except Exception as e:
            print(f"Warning: Model query failed: {e}")
            return False


class FalseAnalogyBreakerAgent:
    """Tests if analogy is structural via counter-examples"""
    
    def __init__(self, model_url: str = "http://localhost:11434"):
        self.model_url = model_url
        self.model_name = "qwen2.5:14b"
    
    def challenge(self, insight: Dict[str, Any], source_claims: List[Dict[str, Any]]) -> ChallengeResult:
        """Generate and test counter-example"""
        insight_text = insight.get('content', {}).get('text', '')
        claims_text = "\n".join([
            f"- [{c.get('domain', 'unknown')}] {c.get('content', {}).get('text', '')}"
            for c in source_claims
        ])
        
        counter_example = self._generate_counter_example(insight_text, claims_text)
        if not counter_example:
            return ChallengeResult(
                passed=True, confidence_adjustment=0.9,
                reasoning="No meaningful counter-example generated - analogy may be robust",
                details={'counter_example': None, 'judgment': None}
            )
        
        judgment = self._evaluate_counter_example(insight_text, counter_example)
        
        if 'survives' in judgment.lower() or 'holds' in judgment.lower():
            return ChallengeResult(
                passed=True, confidence_adjustment=1.0,
                reasoning="Analogy survived counter-example",
                details={'counter_example': counter_example, 'judgment': judgment, 'result': 'survived'}
            )
        elif 'breaks' in judgment.lower() or 'invalid' in judgment.lower():
            return ChallengeResult(
                passed=False, confidence_adjustment=0.5,
                reasoning="Counter-example revealed weakness",
                details={'counter_example': counter_example, 'judgment': judgment, 'result': 'broken'}
            )
        else:
            return ChallengeResult(
                passed=True, confidence_adjustment=0.8,
                reasoning="Counter-example test inconclusive",
                details={'counter_example': counter_example, 'judgment': judgment, 'result': 'inconclusive'}
            )
    
    def _generate_counter_example(self, insight_text: str, claims_text: str) -> Optional[str]:
        """Generate counter-example"""
        prompt = f"""Test this analogy:

ANALOGY: {insight_text}

EVIDENCE:
{claims_text}

Generate ONE counter-example that tests if analogy is structural or superficial.

Format:
COUNTER-EXAMPLE: [scenario in one domain]
PREDICTION: [what should happen in other domain if analogy holds]

Generate:"""

        try:
            response = requests.post(
                f"{self.model_url}/api/generate",
                json={"model": self.model_name, "prompt": prompt, "stream": False,
                      "options": {"temperature": 0.7, "num_predict": 200}},
                timeout=45
            )
            if response.status_code == 200:
                return response.json().get('response', '').strip()
            return None
        except Exception as e:
            print(f"Warning: Counter-example generation failed: {e}")
            return None
    
    def _evaluate_counter_example(self, insight_text: str, counter_example: str) -> str:
        """Evaluate if counter-example breaks analogy"""
        prompt = f"""Evaluate counter-example:

ANALOGY: {insight_text}

COUNTER-EXAMPLE: {counter_example}

Does this show:
A) Analogy SURVIVES (structural similarity holds)
B) Analogy BREAKS (superficial, fails under scrutiny)  
C) INCONCLUSIVE

Your judgment (A, B, or C with brief explanation):"""

        try:
            response = requests.post(
                f"{self.model_url}/api/generate",
                json={"model": self.model_name, "prompt": prompt, "stream": False,
                      "options": {"temperature": 0.3, "num_predict": 150}},
                timeout=30
            )
            if response.status_code == 200:
                return response.json().get('response', '').strip()
            return "Inconclusive"
        except Exception as e:
            print(f"Warning: Evaluation failed: {e}")
            return "Inconclusive"


path: adversarial_rechallenge.py
content:  #!/usr/bin/env python3
"""
openclaw_tasks/adversarial_rechallenge.py
Task 4 — Adversarial Re-Challenge

Periodically selects high-confidence insights (> 0.75) and re-runs a
lightweight adversarial challenge using a 2-agent subset (boundary + null_hypothesis).
This approximates RFC-0007 confidence decay without needing the full scheduler.

Rate limit: max 1 re-challenge per 4-hour window. Tracked in memory/heartbeat-state.json.

New CHALLENGE_RESULT events are appended to the substrate (append-only).
LearningSystem.record_rechallenge_result() is called if self_improvement.py is present.

Usage:
  python adversarial_rechallenge.py [db_path] [--classes boundary,null_hypothesis]
  python adversarial_rechallenge.py [db_path] --insight-id <uuid>
"""

import sys
import json
import os
from datetime import datetime, timedelta
from pathlib import Path

WORKSPACE = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(WORKSPACE))

from substrate import Substrate, EventType

RATE_LIMIT_HOURS = 4
HIGH_CONFIDENCE_THRESHOLD = 0.75
HEARTBEAT_STATE_FILE = WORKSPACE / "memory" / "heartbeat-state.json"

# Default 2-agent subset (fast, avoids full 90-second 3-agent cycle)
DEFAULT_AGENT_SUBSET = ["boundary", "null_hypothesis"]


def load_heartbeat_state() -> dict:
    if HEARTBEAT_STATE_FILE.exists():
        try:
            return json.loads(HEARTBEAT_STATE_FILE.read_text())
        except Exception:
            pass
    return {}


def save_heartbeat_state(state: dict):
    HEARTBEAT_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    HEARTBEAT_STATE_FILE.write_text(json.dumps(state, indent=2))


def is_rate_limited(state: dict) -> bool:
    """Return True if a rechallenge ran within the last RATE_LIMIT_HOURS."""
    last_run = state.get("last_rechallenge_utc")
    if not last_run:
        return False
    try:
        last_dt = datetime.fromisoformat(last_run)
        return (datetime.utcnow() - last_dt) < timedelta(hours=RATE_LIMIT_HOURS)
    except Exception:
        return False


def run_lightweight_challenge(insight_event, agent_subset: list) -> dict:
    """
    Runs a subset adversarial challenge without the full ChallengeOrchestrator.
    Falls back to stub output if adversarial module not importable.

    Returns a verdict dict compatible with write_challenge_result().
    """
    try:
        from adversarial import BoundaryViolationAgent, NullHypothesisAgent, ChallengeOrchestrator

        # Map subset names to agent classes
        agent_map = {
            "boundary": BoundaryViolationAgent,
            "null_hypothesis": NullHypothesisAgent,
        }
        selected = {k: v for k, v in agent_map.items() if k in agent_subset}

        if not selected:
            return _stub_verdict(insight_event, "no_agents_available")

        results = {}
        for name, AgentClass in selected.items():
            agent = AgentClass()
            insight_text = insight_event.content.get("insight", "")
            domain = insight_event.domain
            try:
                result = agent.challenge(insight_text, domain)
                results[name] = result
            except Exception as e:
                results[name] = {"passed": False, "reason": str(e)}

        passes = sum(1 for r in results.values() if r.get("passed", False))
        total = len(results)

        # Confidence adjustment: -0.05 per failure beyond 1
        failures = total - passes
        adjustment = round(-0.05 * failures, 4)

        category = (
            "re_validated" if passes == total
            else "re_challenged" if passes >= total / 2
            else "re_rejected"
        )

        return {
            "agent_results": results,
            "passes": passes,
            "total": total,
            "category": category,
            "confidence_adjustment": adjustment,
            "agent_subset": agent_subset,
            "is_rechallenge": True,
        }

    except ImportError:
        return _stub_verdict(insight_event, "adversarial_module_unavailable")


def _stub_verdict(insight_event, reason: str) -> dict:
    """Fallback when adversarial module is not available."""
    return {
        "agent_results": {},
        "passes": 0,
        "total": 0,
        "category": "rechallenge_skipped",
        "confidence_adjustment": 0.0,
        "agent_subset": [],
        "is_rechallenge": True,
        "skip_reason": reason,
    }


def wire_learning_system(insight_event, verdict: dict, substrate: Substrate):
    """
    Call LearningSystem.record_rechallenge_result() if self_improvement.py present.
    Monitoring integration §4.5.
    """
    try:
        from self_improvement import LearningSystem
        ls = LearningSystem()

        # Build lightweight validation dict compatible with record_result()
        validation = {
            "validation_category": verdict.get("category", "unknown"),
            "passes": verdict.get("passes", 0),
            "total_agents": verdict.get("total", 0),
            "confidence_adjustment": verdict.get("confidence_adjustment", 0.0),
        }

        # Source claims = parent_ids of insight
        source_events = []
        for pid in insight_event.parent_ids:
            ev = substrate.get_event(pid)
            if ev:
                source_events.append(ev)

        # Use existing record_result if rechallenge wrapper not yet added
        if hasattr(ls, "record_rechallenge_result"):
            ls.record_rechallenge_result(insight_event, verdict)
        else:
            ls.record_result(insight_event.content, validation, source_events)

    except ImportError:
        pass  # LearningSystem not present — silent skip
    except Exception as e:
        print(f"[adversarial_rechallenge] LearningSystem error (non-fatal): {e}", file=sys.stderr)


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("db_path", nargs="?", default=str(WORKSPACE / "epistemic.db"))
    parser.add_argument("--insight-id", default=None)
    parser.add_argument("--classes", default=",".join(DEFAULT_AGENT_SUBSET),
                        help="Comma-separated agent subset: boundary,null_hypothesis")
    parser.add_argument("--force", action="store_true",
                        help="Bypass rate limit (for testing)")
    args = parser.parse_args()

    agent_subset = [c.strip() for c in args.classes.split(",") if c.strip()]
    state = load_heartbeat_state()

    # Rate limit check
    if not args.force and is_rate_limited(state):
        result = {
            "status": "rate_limited",
            "next_eligible": (
                datetime.fromisoformat(state["last_rechallenge_utc"]) +
                timedelta(hours=RATE_LIMIT_HOURS)
            ).isoformat(),
        }
        print(json.dumps(result, indent=2))
        return

    substrate = Substrate(args.db_path)
    date_str = datetime.utcnow().strftime("%Y-%m-%d")

    # Select insight to rechallenge
    if args.insight_id:
        insight = substrate.get_event(args.insight_id)
        if not insight or insight.event_type != EventType.INSIGHT_GENERATED:
            print(json.dumps({"error": f"Insight {args.insight_id} not found or wrong type"}))
            substrate.close()
            sys.exit(1)
    else:
        candidates = substrate.get_insights_above_confidence(HIGH_CONFIDENCE_THRESHOLD)
        if not candidates:
            result = {
                "status": "no_candidates",
                "threshold": HIGH_CONFIDENCE_THRESHOLD,
                "timestamp": datetime.utcnow().isoformat(),
            }
            print(json.dumps(result, indent=2))
            substrate.close()
            return
        # Pick the highest-confidence insight (most epistemic risk)
        insight = max(candidates, key=lambda e: e.confidence)

    print(f"[adversarial_rechallenge] Re-challenging {insight.event_id[:8]}... "
          f"(conf={insight.confidence:.2f})", file=sys.stderr)

    verdict = run_lightweight_challenge(insight, agent_subset)

    # Append result to substrate (append-only invariant maintained)
    adj = verdict.get("confidence_adjustment", 0.0)
    substrate.write_challenge_result(
        insight_id=insight.event_id,
        agent_id="monitoring_agent",
        verdict=verdict,
        confidence_adjustment=adj
    )

    # Wire into learning system
    wire_learning_system(insight, verdict, substrate)

    # Update rate limit state
    state["last_rechallenge_utc"] = datetime.utcnow().isoformat()
    state["last_rechallenge_insight_id"] = insight.event_id
    save_heartbeat_state(state)

    # Write to openclaw-runs log
    runs_dir = WORKSPACE / "memory" / "openclaw-runs"
    runs_dir.mkdir(parents=True, exist_ok=True)
    log_file = runs_dir / f"{date_str}.json"
    existing = []
    if log_file.exists():
        try:
            existing = json.loads(log_file.read_text())
        except Exception:
            pass

    run_record = {
        "task": "adversarial_rechallenge",
        "timestamp": datetime.utcnow().isoformat(),
        "insight_id": insight.event_id,
        "prior_confidence": insight.confidence,
        "verdict": verdict,
        "new_confidence_estimate": round(insight.confidence + adj, 4),
    }
    existing.append(run_record)
    log_file.write_text(json.dumps(existing, indent=2))
    substrate.close()

    print(json.dumps(run_record, indent=2))


if __name__ == "__main__":
    main()

path: agent.py
content: """
epistemic_council/agent.py

Domain-specialist agents. Each one owns a domain, reasons within it,
and writes claims to the substrate. They don't talk to each other directly —
all interaction goes through the substrate.

The agent is a thin wrapper around three things:
  1. A domain definition (scope, boundaries, what it can and can't claim)
  2. A prompt strategy (how we frame queries to the model for this domain)
  3. A connection to the substrate (where output lands)

The model itself is handled by ModelClient — agents don't know or care
whether that's a local Ollama instance or an API call.
"""

import time
import uuid
from dataclasses import dataclass, field
from typing import Optional

from substrate import Substrate, InsightType


# ---------------------------------------------------------------------------
# Domain definition
# ---------------------------------------------------------------------------

@dataclass
class DomainDefinition:
    """
    Defines what a domain agent can and can't do.
    This is the boundary the agent is supposed to stay within.
    The adversarial layer will check whether it actually does.
    """
    name: str                                    # e.g. "computer_science"
    description: str                             # Human-readable scope
    key_concepts: list[str] = field(default_factory=list)   # Core vocabulary
    boundary_rules: list[str] = field(default_factory=list) # What's out of scope
    overlap_zones: list[str] = field(default_factory=list)  # Where other domains touch


# Predefined domains for MVP. These are the two we start with.

DOMAIN_COMPUTER_SCIENCE = DomainDefinition(
    name="computer_science",
    description=(
        "Algorithm design, computational complexity, data structures, "
        "optimization problems, search strategies, distributed systems, "
        "and formal methods in computing."
    ),
    key_concepts=[
        "algorithm", "complexity", "optimization", "search", "graph",
        "sorting", "hashing", "parallelism", "convergence", "state space",
        "fitness function", "heuristic", "greedy", "dynamic programming",
        "divide and conquer", "distributed consensus",
    ],
    boundary_rules=[
        "Do not make claims about biological mechanisms or evolution as causal explanations.",
        "Do not claim biological systems 'invented' or 'designed' algorithms.",
        "Stick to computational and mathematical properties of systems.",
    ],
    overlap_zones=[
        "Optimization theory (shared with biology via evolutionary algorithms)",
        "Graph theory (shared with biology via network structures)",
        "Emergent behavior (shared with biology via swarm systems)",
    ],
)

DOMAIN_BIOLOGY = DomainDefinition(
    name="biology",
    description=(
        "Evolutionary systems, natural selection, swarm behavior, "
        "biological networks, adaptation mechanisms, ecological optimization, "
        "and emergent properties of biological populations."
    ),
    key_concepts=[
        "evolution", "natural selection", "fitness", "adaptation", "mutation",
        "population", "swarm", "colony", "emergence", "ecosystem",
        "genetic algorithm", "ant colony", "slime mold", "neural network (biological)",
        "ecological niche", "co-evolution", "selection pressure",
    ],
    boundary_rules=[
        "Do not make claims about computational complexity classes.",
        "Do not claim biological systems are 'running algorithms' in a literal sense.",
        "Stick to observed biological phenomena and mechanisms.",
    ],
    overlap_zones=[
        "Optimization (shared with CS via evolutionary computation)",
        "Network structure (shared with CS via biological networks)",
        "Emergent behavior (shared with CS via swarm intelligence)",
    ],
)

DOMAINS = {
    "computer_science": DOMAIN_COMPUTER_SCIENCE,
    "biology": DOMAIN_BIOLOGY,
}


# ---------------------------------------------------------------------------
# Model client — abstraction over how we talk to the model
# ---------------------------------------------------------------------------

class ModelClient:
    """
    Talks to a local model via Ollama HTTP API.
    
    Setup (do this once before running anything):
        ollama pull qwen2.5:14b
    
    Or whatever model you want. The agent doesn't care about the model name —
    it's configured at init time.
    """

    def __init__(self, model_name: str = "qwen2.5:14b", base_url: str = "http://localhost:11434"):
        self.model_name = model_name
        self.base_url = base_url.rstrip("/")
        self._verify_connection()

    def _verify_connection(self):
        """Check that Ollama is running and the model is available."""
        import requests
        try:
            resp = requests.get(f"{self.base_url}/api/tags", timeout=5)
            resp.raise_for_status()
            available = [m["name"] for m in resp.json().get("models", [])]
            if self.model_name not in available:
                print(f"⚠️  Model '{self.model_name}' not found locally.")
                print(f"    Run: ollama pull {self.model_name}")
                print(f"    Available: {available}")
                raise RuntimeError(f"Model {self.model_name} not available")
            print(f"✅ Model client connected: {self.model_name}")
        except requests.ConnectionError:
            raise RuntimeError(
                "Cannot connect to Ollama. Make sure it's running:\n"
                "    ollama serve"
            )

    def generate(self, prompt: str, temperature: float = 0.3) -> str:
        """
        Send a prompt, get a response. Blocking call.
        Temperature is low by default — we want consistent, careful reasoning,
        not creative hallucination.
        """
        import requests
        resp = requests.post(
            f"{self.base_url}/api/generate",
            json={
                "model": self.model_name,
                "prompt": prompt,
                "stream": False,
                "options": {"temperature": temperature},
            },
            timeout=120,
        )
        resp.raise_for_status()
        return resp.json()["response"].strip()


# ---------------------------------------------------------------------------
# Domain agent
# ---------------------------------------------------------------------------

class DomainAgent:
    """
    A single domain-specialist agent. Owns one domain, reasons within it,
    writes claims to the shared substrate.

    Usage:
        substrate = Substrate("epistemic.db")
        client = ModelClient("qwen2.5:14b")
        agent = DomainAgent("computer_science", substrate, client)
        agent.reason("How do ant colony optimization algorithms work?")
    """

    def __init__(
        self,
        domain_name: str,
        substrate: Substrate,
        model_client: ModelClient,
    ):
        if domain_name not in DOMAINS:
            raise ValueError(f"Unknown domain: {domain_name}. Available: {list(DOMAINS.keys())}")

        self.domain = DOMAINS[domain_name]
        self.agent_id = f"agent_{domain_name}_{uuid.uuid4().hex[:8]}"
        self.substrate = substrate
        self.model = model_client
        print(f"🤖 Agent initialized: {self.agent_id} (domain: {self.domain.name})")

    def reason(self, query: str, context: Optional[str] = None) -> list[str]:
        """
        Main entry point. Takes a query, produces claims, writes them
        to the substrate. Returns the list of claim IDs that were written.

        The prompt is structured to:
          1. Ground the agent in its domain definition
          2. Enforce boundary rules
          3. Ask for explicit confidence scores
          4. Require reasoning traces (not just conclusions)
        """
        prompt = self._build_prompt(query, context)
        print(f"🔍 {self.domain.name}: reasoning on query...")

        raw_response = self.model.generate(prompt)
        print(f"📝 {self.domain.name}: parsing response...")

        claims = self._parse_claims(raw_response)
        claim_ids = []

        for claim in claims:
            cid = self.substrate.write_claim(
                agent_id=self.agent_id,
                domain=self.domain.name,
                claim_text=claim["text"],
                confidence=claim["confidence"],
                reasoning_trace=claim.get("reasoning", ""),
            )
            # write_claim now returns SubstrateEvent; extract event_id
            if hasattr(cid, 'event_id'):
                cid = cid.event_id
            claim_ids.append(cid)
            print(f"   ✓ Claim written (conf={claim['confidence']:.2f}): {claim['text'][:80]}...")

        print(f"📦 {self.domain.name}: {len(claim_ids)} claims written to substrate")
        return claim_ids

    # -----------------------------------------------------------------
    # Prompt construction
    # -----------------------------------------------------------------

    def _build_prompt(self, query: str, context: Optional[str] = None) -> str:
        """
        Build the reasoning prompt. This is where domain boundaries
        get enforced — at the prompt level. The adversarial layer will
        verify whether the model actually respected them.
        """
        boundary_text = "\n".join(f"  - {r}" for r in self.domain.boundary_rules)
        concepts_text = ", ".join(self.domain.key_concepts)

        prompt = f"""You are an expert reasoning agent specialized in {self.domain.name}.

DOMAIN: {self.domain.description}

KEY CONCEPTS: {concepts_text}

BOUNDARY RULES (you must not violate these):
{boundary_text}

TASK: Analyze the following query and produce a set of distinct claims.
Each claim must be:
  - A single, falsifiable statement within your domain
  - Accompanied by a confidence score (0.0 to 1.0) reflecting how confident
    you are based on your domain knowledge
  - Accompanied by a brief reasoning trace explaining WHY you believe it

QUERY: {query}
"""

        if context:
            prompt += f"\nADDITIONAL CONTEXT:\n{context}\n"

        prompt += """
FORMAT YOUR RESPONSE as a structured list. Each claim must follow this exact format:

CLAIM: [your claim statement]
CONFIDENCE: [a number between 0.0 and 1.0]
REASONING: [1-3 sentences explaining your reasoning]
---

Produce between 3 and 7 claims. Be precise. Stay within your domain boundaries.
"""
        return prompt

    # -----------------------------------------------------------------
    # Response parsing
    # -----------------------------------------------------------------

    def _parse_claims(self, raw_response: str) -> list[dict]:
        """
        Parse the model's structured response into claim objects.
        Robust to minor formatting variations — models don't always
        follow instructions perfectly.
        """
        claims = []
        current = {}

        for line in raw_response.split("\n"):
            line = line.strip()

            if line.startswith("CLAIM:"):
                # If we already have a complete claim buffered, save it
                if current and "text" in current and "confidence" in current:
                    claims.append(current)
                current = {"text": line[len("CLAIM:"):].strip()}

            elif line.startswith("CONFIDENCE:") and current:
                raw_conf = line[len("CONFIDENCE:"):].strip()
                current["confidence"] = self._parse_confidence(raw_conf)

            elif line.startswith("REASONING:") and current:
                current["reasoning"] = line[len("REASONING:"):].strip()

            elif line == "---":
                if current and "text" in current and "confidence" in current:
                    claims.append(current)
                    current = {}

        # Catch the last claim if there's no trailing ---
        if current and "text" in current and "confidence" in current:
            claims.append(current)

        # Fallback: if parsing produced nothing, treat the whole response
        # as a single low-confidence claim so we don't silently drop output
        if not claims:
            print(f"⚠️  {self.domain.name}: structured parsing failed, falling back to raw response")
            claims = [{
                "text": raw_response[:500],
                "confidence": 0.2,
                "reasoning": "Fallback: model did not follow structured format",
            }]

        return claims

    def _parse_confidence(self, raw: str) -> float:
        """
        Extract a float from whatever the model gave us.
        Handles "0.7", "0.7/1.0", "70%", plain garbage, etc.
        """
        raw = raw.strip().rstrip(".")
        # Strip trailing /1.0 or /1
        if "/" in raw:
            raw = raw.split("/")[0].strip()
        # Strip % sign
        raw = raw.replace("%", "").strip()

        try:
            val = float(raw)
            # If they wrote 70 meaning 70%, normalize
            if val > 1.0:
                val = val / 100.0
            return max(0.0, min(1.0, val))
        except ValueError:
            return 0.3  # Default low confidence if we can't parse it

path: boundary_audit.py
content:  #!/usr/bin/env python3
"""
openclaw_tasks/boundary_audit.py
Task 5 — Domain Boundary Enforcement Audit

Scans recent claims using the same regex forbidden-term logic as
BoundaryViolationAgent (existing adversarial layer) to detect boundary
leakage at the claim level — not just at the insight level.

Gap §5.5: boundary enforcement currently only occurs in the adversarial
layer after insight generation. This audit extends coverage to individual claims.

Does NOT write to substrate directly.
Violations logged as suggestions in memory/openclaw-runs/YYYY-MM-DD.json.
Human or next pipeline run must decide whether to escalate.

Usage:
  python boundary_audit.py [db_path] [--domain DOMAIN] [--recent N]
"""

import sys
import json
import re
from datetime import datetime
from pathlib import Path

WORKSPACE = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(WORKSPACE))

from substrate import Substrate, EventType

# Default: audit last N claims per domain
DEFAULT_RECENT_N = 20

# Domain forbidden terms — mirrors BoundaryViolationAgent logic from adversarial.py.
# These are terms that should NOT appear in claims for the given domain.
# Extend this dict as more domain agents are added.
DOMAIN_FORBIDDEN_TERMS: dict = {
    "computer_science": [
        r"\bprotein fold",
        r"\bcell\b",
        r"\bneuron\b(?!.*network)",  # 'neuron' unless followed by 'network'
        r"\bDNA\b",
        r"\bgenome\b",
        r"\bspecies\b",
        r"\bmarket price\b",
        r"\bfiscal policy\b",
    ],
    "biology": [
        r"\bsort algorithm\b",
        r"\btime complexity\b",
        r"\bO\(n",
        r"\bhash table\b",
        r"\bcompiler\b",
        r"\bstock price\b",
        r"\bGDP\b",
        r"\binflation rate\b",
    ],
    "economics": [
        r"\bprotein\b",
        r"\bRNA\b",
        r"\bgraph traversal\b",
        r"\bbinary tree\b",
    ],
    # Add more domains here as agents are instantiated
}


def check_claim_boundary(claim_text: str, domain: str) -> list:
    """
    Returns list of boundary violations found in claim_text for the given domain.
    Each violation is a dict with pattern and match.
    """
    forbidden = DOMAIN_FORBIDDEN_TERMS.get(domain, [])
    violations = []
    for pattern in forbidden:
        match = re.search(pattern, claim_text, re.IGNORECASE)
        if match:
            violations.append({
                "pattern": pattern,
                "matched_text": match.group(0),
                "position": match.start(),
            })
    return violations


def run_boundary_audit(db_path: str, domain: str = None, recent_n: int = DEFAULT_RECENT_N) -> dict:
    substrate = Substrate(db_path)
    total = substrate.event_count()

    if total < 1:
        substrate.close()
        return {"status": "no_data", "timestamp": datetime.utcnow().isoformat()}

    # Get recent claims
    all_claims = substrate.get_events_by_type(EventType.CLAIM_CREATED, domain=domain)
    # Most recent N
    recent_claims = sorted(all_claims, key=lambda e: e.timestamp, reverse=True)[:recent_n]

    violations_found = []
    clean_count = 0

    for claim in recent_claims:
        claim_text = claim.content.get("claim", "")
        violations = check_claim_boundary(claim_text, claim.domain)

        if violations:
            violations_found.append({
                "claim_id": claim.event_id,
                "domain": claim.domain,
                "confidence": claim.confidence,
                "timestamp": claim.timestamp,
                "violations": violations,
                "claim_snippet": claim_text[:150],
                "suggested_action": "flag:boundary_violation",
            })
        else:
            clean_count += 1

    # Also check insights for missing cross_domain_flag
    insights = substrate.get_insights()
    missing_flag_insights = []
    for ins in insights:
        content = ins.content
        if "cross_domain_flag" not in content:
            missing_flag_insights.append({
                "insight_id": ins.event_id,
                "domain": ins.domain,
                "confidence": ins.confidence,
                "note": "cross_domain_flag field missing from content (Gap §4.2)",
            })

    substrate.close()

    result = {
        "status": "ok",
        "timestamp": datetime.utcnow().isoformat(),
        "claims_audited": len(recent_claims),
        "clean_claims": clean_count,
        "boundary_violations": violations_found,
        "violation_count": len(violations_found),
        "insights_missing_cross_domain_flag": missing_flag_insights[:10],
        "alert": "REVIEW_NEEDED" if violations_found else "OK",
    }

    return result


def write_audit_log(result: dict, workspace: Path):
    runs_dir = workspace / "memory" / "openclaw-runs"
    runs_dir.mkdir(parents=True, exist_ok=True)
    date_str = datetime.utcnow().strftime("%Y-%m-%d")
    log_file = runs_dir / f"{date_str}.json"
    existing = []
    if log_file.exists():
        try:
            existing = json.loads(log_file.read_text())
        except Exception:
            pass
    existing.append({"task": "boundary_audit", **result})
    log_file.write_text(json.dumps(existing, indent=2))


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("db_path", nargs="?", default=str(WORKSPACE / "epistemic.db"))
    parser.add_argument("--domain", default=None)
    parser.add_argument("--recent", type=int, default=DEFAULT_RECENT_N)
    args = parser.parse_args()

    result = run_boundary_audit(args.db_path, args.domain, args.recent)
    write_audit_log(result, WORKSPACE)
    print(json.dumps(result, indent=2))

    if result.get("alert") == "REVIEW_NEEDED":
        print(f"\n[boundary_audit] {result['violation_count']} violation(s) found. "
              "Logged to memory/openclaw-runs/. Review before next pipeline run.", file=sys.stderr)


if __name__ == "__main__":
    main()

path: challenge_orchestrator.py
content: """
epistemic_council/challenge_orchestrator.py

Orchestrates adversarial challenges on insights.
Runs all three adversarial agents and determines validation status.
"""

from typing import Dict, List, Any, Tuple
from adversarial import (
    BoundaryViolationAgent,
    NullHypothesisAgent,
    FalseAnalogyBreakerAgent,
    ChallengeResult
)
from substrate import Substrate


class ChallengeOrchestrator:
    """Runs insights through adversarial validation"""
    
    def __init__(self, substrate: Substrate, model_url: str = "http://localhost:11434"):
        self.substrate = substrate
        self.agents = {
            'boundary': BoundaryViolationAgent(substrate),
            'null_hypothesis': NullHypothesisAgent(substrate, model_url),
            'false_analogy': FalseAnalogyBreakerAgent(model_url)
        }
    
    def run_challenges(self, insight_event, query_text: str) -> Dict[str, Any]:
        """
        Run all challenges on an insight.
        
        Returns:
            {
                'passes': int,
                'results': dict,
                'final_confidence': float,
                'status': str  # 'validated', 'mostly_validated', 'challenged', 'rejected'
            }
        """
        results = {}
        passes = 0
        confidence_adjustments = []
        
        # Convert event to dict for agents
        insight_dict = {
            'content': insight_event.content,
            'confidence': insight_event.confidence
        }
        
        print(f"\n🔬 Challenging insight (conf={insight_event.confidence:.2f}):")
        print(f"   {insight_event.content.get('text', '')[:80]}...")
        
        # Challenge 1: Boundary Violation Check
        print(f"\n  [1/3] Boundary Violation Check...")
        try:
            boundary_result = self.agents['boundary'].challenge(insight_dict)
            results['boundary'] = boundary_result
            if boundary_result.passed:
                passes += 1
                print(f"    ✓ PASS")
            else:
                print(f"    ✗ FAIL: {boundary_result.reasoning}")
            confidence_adjustments.append(boundary_result.confidence_adjustment)
            
            # Write challenge result to substrate
            self.substrate.write_challenge_result(
                insight_id=insight_event.event_id,
                agent_id='boundary_agent',
                verdict={'type': 'boundary_violation', 'passed': boundary_result.passed, 'reasoning': boundary_result.reasoning},
                confidence_adjustment=boundary_result.confidence_adjustment
            )
        except Exception as e:
            print(f"    ⚠️  ERROR: {e}")
            boundary_result = ChallengeResult(True, 1.0, f"Error: {e}", {})
            results['boundary'] = boundary_result
            confidence_adjustments.append(1.0)
        
        # Challenge 2: Null Hypothesis Test
        print(f"\n  [2/3] Null Hypothesis Test...")
        try:
            null_result = self.agents['null_hypothesis'].challenge(insight_dict, query_text)
            results['null_hypothesis'] = null_result
            if null_result.passed:
                passes += 1
                print(f"    ✓ PASS: {null_result.reasoning}")
            else:
                print(f"    ✗ FAIL: {null_result.reasoning}")
            confidence_adjustments.append(null_result.confidence_adjustment)
            
            # Write challenge result to substrate
            self.substrate.write_challenge_result(
                insight_id=insight_event.event_id,
                agent_id='null_hypothesis_agent',
                verdict={'type': 'null_hypothesis', 'passed': null_result.passed, 'reasoning': null_result.reasoning},
                confidence_adjustment=null_result.confidence_adjustment
            )
        except Exception as e:
            print(f"    ⚠️  ERROR: {e}")
            null_result = ChallengeResult(True, 1.0, f"Error: {e}", {})
            results['null_hypothesis'] = null_result
            confidence_adjustments.append(1.0)
        
        # Challenge 3: False Analogy Test
        print(f"\n  [3/3] False Analogy Test...")
        try:
            # Get source claims for this challenge
            source_claim_ids = insight_event.content.get('source_claim_ids', [])
            source_claims = []
            for claim_id in source_claim_ids:
                event = self.substrate.get_event(claim_id)
                if event:
                    source_claims.append({
                        'domain': event.domain,
                        'content': event.content
                    })
            
            analogy_result = self.agents['false_analogy'].challenge(insight_dict, source_claims)
            results['false_analogy'] = analogy_result
            if analogy_result.passed:
                passes += 1
                print(f"    ✓ PASS: {analogy_result.reasoning}")
            else:
                print(f"    ✗ FAIL: {analogy_result.reasoning}")
            confidence_adjustments.append(analogy_result.confidence_adjustment)
            
            # Write challenge result to substrate
            self.substrate.write_challenge_result(
                insight_id=insight_event.event_id,
                agent_id='false_analogy_agent',
                verdict={'type': 'false_analogy', 'passed': analogy_result.passed, 'reasoning': analogy_result.reasoning},
                confidence_adjustment=analogy_result.confidence_adjustment
            )
        except Exception as e:
            print(f"    ⚠️  ERROR: {e}")
            analogy_result = ChallengeResult(True, 1.0, f"Error: {e}", {})
            results['false_analogy'] = analogy_result
            confidence_adjustments.append(1.0)
        
        # Calculate final confidence
        original_conf = insight_event.confidence
        adjustment_factor = sum(confidence_adjustments) / len(confidence_adjustments)
        final_confidence = original_conf * adjustment_factor
        
        # Determine status
        if passes == 3:
            status = 'validated'
        elif passes == 2:
            status = 'mostly_validated'
        elif passes == 1:
            status = 'challenged'
        else:
            status = 'rejected'
        
        print(f"\n  Result: {passes}/3 passed, status={status}")
        print(f"  Confidence: {original_conf:.2f} → {final_confidence:.2f}")
        
        # Write overall result to substrate
        self.substrate.write_challenge_result(
            insight_id=insight_event.event_id,
            agent_id='challenge_orchestrator',
            verdict={'passes': passes, 'total': 3, 'status': status},
            confidence_adjustment=final_confidence - original_conf
        )
        
        return {
            'passes': passes,
            'results': results,
            'final_confidence': final_confidence,
            'status': status,
            'original_confidence': original_conf
        }
    
    def validate_batch(self, insight_events: List, query_text: str) -> Tuple[List, List, List]:
        """
        Validate a batch of insights.
        
        Returns:
            (validated, challenged, rejected) - three lists of (insight, result) tuples
        """
        validated = []
        challenged = []
        rejected = []
        
        for insight in insight_events:
            result = self.run_challenges(insight, query_text)
            
            if result['status'] == 'validated':
                validated.append((insight, result))
            elif result['status'] in ['challenged', 'mostly_validated']:
                challenged.append((insight, result))
            else:
                rejected.append((insight, result))
        
        return validated, challenged, rejected


path: detection.py
content: """
epistemic_council/detection.py

Cross-domain detection engine. This is the first piece that actually tests
whether Epistemic Council produces anything useful.

MVP scope: Analogical detection only (A-type insights). We look for structural
similarity between claim sets from two different domains. The other insight
types (C, O, S) come later once we know A-type detection works.

How it works:
  1. Pull visible claims from both domains off the substrate
  2. Send them to the model with a structured prompt that asks it to
     identify structural similarities
  3. Parse the model's output into candidate insights
  4. Apply a cross-domain confidence penalty (domain distance)
  5. Write anything above the threshold to the substrate as an insight

The confidence penalty is the key mechanism here. An insight that maps
directly between closely-related concepts gets penalized less than one
that maps between distant concepts. This is empirically calibratable —
after the first few runs, we'll have data on whether the penalties are
set right.
"""

import time
import uuid
from dataclasses import dataclass, field
from typing import Optional

from substrate import Substrate, InsightType
from agent import ModelClient, DOMAINS


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

@dataclass
class DetectionConfig:
    """
    Tunable parameters for cross-domain detection.
    These are initial guesses. They get calibrated after the first
    human review pass — don't treat them as gospel.
    """
    # Minimum confidence an insight must have before it's written
    min_confidence_threshold: float = 0.25

    # Base penalty applied to all cross-domain insights.
    # Rationale: any cross-domain claim is inherently less certain than
    # a within-domain claim. This is the floor penalty.
    base_cross_domain_penalty: float = 0.15

    # Additional penalty per unit of domain distance (0.0 to 1.0).
    # Domain distance is computed from overlap between key concepts.
    # No overlap = distance 1.0 = max penalty. Full overlap = distance 0.0 = no extra penalty.
    distance_penalty_weight: float = 0.2

    # Max insights written per detection run.
    # Prevents flooding the substrate with low-quality signal.
    max_insights_per_run: int = 5


# ---------------------------------------------------------------------------
# Domain distance
# ---------------------------------------------------------------------------

def compute_domain_distance(domain_a: str, domain_b: str) -> float:
    """
    Jaccard distance between two domains based on key concept overlap.

    distance = 1 - |intersection| / |union|

    Returns 0.0 if domains are identical, 1.0 if they share nothing.
    This is a rough proxy. It'll be refined once we have empirical data
    on which domain pairs actually produce valid insights.
    """
    if domain_a not in DOMAINS or domain_b not in DOMAINS:
        return 1.0  # Unknown domain = max distance

    concepts_a = set(DOMAINS[domain_a].key_concepts)
    concepts_b = set(DOMAINS[domain_b].key_concepts)

    intersection = concepts_a & concepts_b
    union = concepts_a | concepts_b

    if not union:
        return 1.0

    return 1.0 - (len(intersection) / len(union))


def compute_confidence_penalty(domain_a: str, domain_b: str, config: DetectionConfig) -> float:
    """
    Total confidence penalty for a cross-domain insight.
    
    penalty = base_penalty + (distance * distance_weight)
    
    Clamped to [0.0, 0.8] — we never want to penalize so hard that
    nothing can survive, and we never want to give a free pass.
    """
    distance = compute_domain_distance(domain_a, domain_b)
    penalty = config.base_cross_domain_penalty + (distance * config.distance_penalty_weight)
    return max(0.0, min(0.8, penalty))


# ---------------------------------------------------------------------------
# Detection engine
# ---------------------------------------------------------------------------

class DetectionEngine:
    """
    Looks at claims from two domains and finds analogical patterns.

    Usage:
        substrate = Substrate("epistemic.db")
        client = ModelClient("qwen2.5:14b")
        engine = DetectionEngine(substrate, client)
        insights = engine.detect("computer_science", "biology")
    """

    def __init__(
        self,
        substrate: Substrate,
        model_client: ModelClient,
        config: Optional[DetectionConfig] = None,
    ):
        self.substrate = substrate
        self.model = model_client
        self.config = config or DetectionConfig()
        self.agent_id = f"detection_engine_{uuid.uuid4().hex[:8]}"

    def detect(self, domain_a: str, domain_b: str) -> list[str]:
        """
        Main entry point. Pulls claims from both domains, runs analogical
        detection, writes valid insights to the substrate.
        Returns list of insight event IDs that were written.
        """
        print(f"\n🔬 Detection engine: scanning {domain_a} ↔ {domain_b}")

        # Pull claims (filter out visibility-pruned events)
        claims_a = [e for e in self.substrate.get_claims_by_domain(domain_a)
                    if not self.substrate.is_visibility_pruned(e.event_id)]
        claims_b = [e for e in self.substrate.get_claims_by_domain(domain_b)
                    if not self.substrate.is_visibility_pruned(e.event_id)]

        if not claims_a or not claims_b:
            print(f"⚠️  No claims in one or both domains. Nothing to detect.")
            print(f"    {domain_a}: {len(claims_a)} claims | {domain_b}: {len(claims_b)} claims")
            return []

        print(f"    {domain_a}: {len(claims_a)} claims | {domain_b}: {len(claims_b)} claims")

        # Compute penalty for this domain pair
        penalty = compute_confidence_penalty(domain_a, domain_b, self.config)
        print(f"    Cross-domain confidence penalty: {penalty:.2f}")

        # Build prompt and query model
        prompt = self._build_detection_prompt(claims_a, claims_b, domain_a, domain_b)
        print(f"🔍 Detection engine: querying model...")
        raw_response = self.model.generate(prompt, temperature=0.2)

        # Parse candidates
        candidates = self._parse_insights(raw_response, domain_a, domain_b)
        print(f"📋 Detection engine: {len(candidates)} candidate insights parsed")

        # Apply penalty and filter
        insight_ids = []
        for candidate in candidates:
            adjusted_confidence = candidate["confidence"] - penalty
            if adjusted_confidence < self.config.min_confidence_threshold:
                print(f"   ✗ Dropped (conf {adjusted_confidence:.2f} < threshold): {candidate['text'][:70]}...")
                continue

            # Cap at max insights per run
            if len(insight_ids) >= self.config.max_insights_per_run:
                print(f"   ✗ Dropped (max {self.config.max_insights_per_run} insights per run reached)")
                break

            iid = self.substrate.write_insight(
                agent_id=self.agent_id,
                domain="cross_domain",
                insight_text=candidate["text"],
                insight_type=InsightType.ANALOGICAL,
                confidence=adjusted_confidence,
                source_claim_ids=candidate["source_claim_ids"],
                cross_domain_flag=True,
            )
            # write_insight returns a SubstrateEvent; extract event_id
            if hasattr(iid, 'event_id'):
                iid = iid.event_id
            insight_ids.append(iid)
            print(f"   ✓ Insight written (conf={adjusted_confidence:.2f}): {candidate['text'][:70]}...")

        print(f"📦 Detection engine: {len(insight_ids)} insights written to substrate\n")
        return insight_ids

    # -----------------------------------------------------------------
    # Prompt construction
    # -----------------------------------------------------------------

    def _build_detection_prompt(
        self,
        claims_a: list,
        claims_b: list,
        domain_a: str,
        domain_b: str,
    ) -> str:
        """
        Build the detection prompt. This is structured to:
          1. Present claims from both domains clearly
          2. Ask specifically for structural analogies (not surface similarities)
          3. Require the model to cite which specific claims support each analogy
          4. Ask for confidence scores
        """
        claims_a_text = "\n".join(
            f"  [CS-{i+1}] (conf={e.confidence:.2f}) {e.content['text']}"
            for i, e in enumerate(claims_a)
        )
        claims_b_text = "\n".join(
            f"  [BIO-{i+1}] (conf={e.confidence:.2f}) {e.content['text']}"
            for i, e in enumerate(claims_b)
        )

        # Build a mapping from index to event_id so we can resolve references later
        # (stored on self temporarily for use during parsing)
        self._current_claim_map_a = {f"CS-{i+1}": e.event_id for i, e in enumerate(claims_a)}
        self._current_claim_map_b = {f"BIO-{i+1}": e.event_id for i, e in enumerate(claims_b)}

        prompt = f"""You are analyzing claims from two different domains to find STRUCTURAL ANALOGIES.

A structural analogy is NOT a surface-level similarity. It is a case where the
underlying mechanism, pattern, or logical structure in one domain maps onto a
similar structure in another domain — even though the specific entities and
context are completely different.

DOMAIN A — {domain_a}:
{claims_a_text}

DOMAIN B — {domain_b}:
{claims_b_text}

TASK: Identify structural analogies between these two sets of claims.
For each analogy you find:
  - Explain the shared structural pattern (not just "both involve optimization")
  - Cite the specific claims from each domain that support it (use the labels like CS-1, BIO-2)
  - Assign a confidence score (0.0 to 1.0) reflecting how strong the structural
    mapping actually is. Be conservative — weak analogies should score below 0.4.

IMPORTANT:
  - Do NOT flag surface similarities (e.g. "both systems have agents" is not an analogy)
  - Do NOT invent connections that aren't supported by the claims above
  - If you find no genuine structural analogies, say so explicitly

FORMAT each analogy exactly like this:

ANALOGY: [description of the structural pattern]
SOURCES: [comma-separated list of claim labels, e.g. CS-2, CS-4, BIO-1, BIO-3]
CONFIDENCE: [number between 0.0 and 1.0]
---

Produce between 0 and 5 analogies. Be precise and conservative.
"""
        return prompt

    # -----------------------------------------------------------------
    # Response parsing
    # -----------------------------------------------------------------

    def _parse_insights(
        self,
        raw_response: str,
        domain_a: str,
        domain_b: str,
    ) -> list[dict]:
        """
        Parse model output into structured insight candidates.
        Resolves claim label references (CS-1, BIO-2) back to actual
        substrate event IDs using the map built during prompt construction.
        """
        insights = []
        current = {}

        for line in raw_response.split("\n"):
            line = line.strip()

            if line.startswith("ANALOGY:"):
                if current and "text" in current and "confidence" in current:
                    insights.append(current)
                current = {"text": line[len("ANALOGY:"):].strip()}

            elif line.startswith("SOURCES:") and current:
                raw_sources = line[len("SOURCES:"):].strip()
                current["source_claim_ids"] = self._resolve_sources(raw_sources)

            elif line.startswith("CONFIDENCE:") and current:
                current["confidence"] = self._parse_confidence(line[len("CONFIDENCE:"):].strip())

            elif line == "---":
                if current and "text" in current and "confidence" in current:
                    insights.append(current)
                    current = {}

        # Catch last entry
        if current and "text" in current and "confidence" in current:
            insights.append(current)

        # Ensure all insights have source_claim_ids (even if empty)
        for insight in insights:
            if "source_claim_ids" not in insight:
                insight["source_claim_ids"] = []

        # If model said "no analogies found" or similar, return empty
        no_analogy_signals = [
            "no genuine", "no structural", "no analogy", "no analogies",
            "cannot identify", "did not find",
        ]
        if not insights and any(s in raw_response.lower() for s in no_analogy_signals):
            print(f"   ℹ️  Model explicitly reported no analogies found")
            return []

        return insights

    def _resolve_sources(self, raw_sources: str) -> list[str]:
        """
        Turn "CS-2, CS-4, BIO-1, BIO-3" into actual substrate event IDs.
        Silently skips any labels it can't resolve — better to have
        partial provenance than to crash.
        """
        resolved = []
        labels = [s.strip().rstrip(".") for s in raw_sources.split(",")]

        for label in labels:
            label = label.strip()
            if label in self._current_claim_map_a:
                resolved.append(self._current_claim_map_a[label])
            elif label in self._current_claim_map_b:
                resolved.append(self._current_claim_map_b[label])
            # else: silently skip unresolvable labels

        return resolved

    def _parse_confidence(self, raw: str) -> float:
        """Same logic as agent — handles variations in model formatting."""
        raw = raw.strip().rstrip(".")
        if "/" in raw:
            raw = raw.split("/")[0].strip()
        raw = raw.replace("%", "").strip()
        try:
            val = float(raw)
            if val > 1.0:
                val = val / 100.0
            return max(0.0, min(1.0, val))
        except ValueError:
            return 0.2

path: epistemic_skill.py
content: """
epistemic_skill.py — OpenClaw skill entry point for Epistemic Council v2.0

OpenClaw invokes this file when a trigger phrase is matched.
All skill entrypoints are routed through dispatch().

Workspace access: READ_WRITE (set in skill.json + workspace.json)
Shell access: enabled (runs run.py, substrate_health.py, etc.)

Available triggers and what they do:
  "run council" / "run pipeline"    → full pipeline run (run.py)
  "substrate status" / "council status" → health check
  "analyze cross-domain"            → detection only (no new claims)
  "find analogies"                  → same as analyze
  "validate claims"                 → validate_claim.py
  "check boundaries"                → boundary_audit.py
  "find gaps"                       → evidence_search.py
  "rechallenge"                     → adversarial_rechallenge.py
"""

import os
import sys
import json
import subprocess
import logging
from pathlib import Path
from datetime import datetime

# ── Path setup ─────────────────────────────────────────────────────────────
SKILL_DIR   = Path(__file__).parent.resolve()          # epistemic_council/
WORKSPACE   = SKILL_DIR.parent.resolve()               # workspace root
MEMORY_DIR  = SKILL_DIR / "memory"
STATE_FILE  = MEMORY_DIR / "heartbeat-state.json"
DB_PATH     = SKILL_DIR / "epistemic.db"

if str(SKILL_DIR) not in sys.path:
    sys.path.insert(0, str(SKILL_DIR))

from substrate import Substrate, EventType

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("epistemic_skill")


# ── Helpers ─────────────────────────────────────────────────────────────────

def _substrate() -> Substrate:
    return Substrate(str(DB_PATH))


def _load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text())
        except Exception:
            pass
    return {}


def _save_state(state: dict):
    MEMORY_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2))


def _run_script(script_name: str, extra_args: list = None) -> tuple[int, str]:
    """Run a task script in the epistemic_council directory. Returns (returncode, output)."""
    cmd = [sys.executable, str(SKILL_DIR / script_name)] + (extra_args or [])
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            cwd=str(SKILL_DIR),
            timeout=300,
        )
        output = result.stdout + (("\n[stderr] " + result.stderr) if result.stderr.strip() else "")
        return result.returncode, output.strip()
    except subprocess.TimeoutExpired:
        return 1, f"[timeout] {script_name} exceeded 300s"
    except Exception as e:
        return 1, f"[error] {script_name}: {e}"


def _query_for_index(index: int) -> str:
    queries = [
        "optimization_under_constraints",
        "emergent_collective_behavior",
        "adaptation_to_changing_environment",
    ]
    return queries[index % len(queries)]


# ── Skill handlers ───────────────────────────────────────────────────────────

def handle_run_pipeline(message: str) -> str:
    """Run the full epistemic council pipeline (run.py)."""
    state = _load_state()

    if state.get("pipeline_running"):
        return "⚠️  Pipeline is already running. Check memory/heartbeat-state.json."

    idx   = state.get("query_rotation_index", 0)
    query = _query_for_index(idx)

    state["pipeline_running"] = True
    state["last_pipeline_start"] = datetime.utcnow().isoformat()
    _save_state(state)

    log.info(f"Starting pipeline with query: {query}")
    rc, output = _run_script("run.py", ["--query", query, "--db", str(DB_PATH)])

    summary = {"query": query, "raw_exit": rc}
    for line in output.splitlines():
        for key in ("Validated:", "Challenged:", "Rejected:", "Total events:"):
            if key in line:
                try:
                    summary[key.rstrip(":")] = int(line.split(":")[-1].strip())
                except ValueError:
                    pass

    state["pipeline_running"]       = False
    state["last_pipeline_run"]      = datetime.utcnow().isoformat()
    state["last_query"]             = query
    state["query_rotation_index"]   = idx + 1
    state["last_run_summary"]       = summary
    _save_state(state)

    if rc != 0:
        return f"❌ Pipeline exited with code {rc}.\n\n{output[-1000:]}"

    validated  = summary.get("Validated",  "?")
    challenged = summary.get("Challenged", "?")
    rejected   = summary.get("Rejected",   "?")
    return (
        f"✅ Pipeline complete — query: {query}\n"
        f"   Validated: {validated}  |  Challenged: {challenged}  |  Rejected: {rejected}\n"
        f"   Results in epistemic.db. Run 'substrate status' for detail."
    )


def handle_status(message: str) -> str:
    """Return substrate health + last run summary."""
    s = _substrate()
    total   = s.event_count()
    state   = _load_state()

    if total < 5:
        s.close()
        return (
            f"Substrate has {total} events — run 'run council' to populate it.\n"
            f"Last pipeline run: {state.get('last_pipeline_run', 'never')}"
        )

    insights   = s.get_insights()
    cs_claims  = s.get_claims_by_domain("computer_science")
    bio_claims = s.get_claims_by_domain("biology")
    integrity  = s.verify_all()
    s.close()

    summary  = state.get("last_run_summary") or {}
    last_run = state.get("last_pipeline_run", "never")

    return (
        f"📊 Epistemic Council — Substrate Status\n"
        f"{'─'*45}\n"
        f"  Total events     : {total}\n"
        f"  CS claims        : {len(cs_claims)}\n"
        f"  Biology claims   : {len(bio_claims)}\n"
        f"  Insights         : {len(insights)}\n"
        f"  Integrity        : {integrity['passed']}/{integrity['total']} passed\n"
        f"{'─'*45}\n"
        f"  Last pipeline run: {last_run}\n"
        f"  Last query       : {state.get('last_query', 'none')}\n"
        f"  Validated        : {summary.get('Validated', '–')}\n"
        f"  Challenged       : {summary.get('Challenged', '–')}\n"
        f"  Rejected         : {summary.get('Rejected', '–')}\n"
    )


def handle_health_check(message: str) -> str:
    """Run substrate_health.py and return the alert level."""
    rc, output = _run_script("substrate_health.py")
    try:
        report    = json.loads(output.split("\n[stderr]")[0].strip())
        alert     = report.get("alert", "UNKNOWN")
        total     = report.get("total_events", "?")
        hr_count  = report.get("high_risk_unverified", {}).get("count", 0)
        orphans   = report.get("orphan_claims", {}).get("count", 0)
        clusters  = report.get("divergence_clusters", {}).get("count", 0)
        prefix = "🔴" if alert == "CRITICAL" else "✅"
        return (
            f"{prefix} Substrate health: {alert}\n"
            f"   Total events          : {total}\n"
            f"   High-risk unverified  : {hr_count}\n"
            f"   Orphan claims         : {orphans}\n"
            f"   Divergence clusters   : {clusters}\n"
        )
    except Exception:
        return f"Health check output (exit {rc}):\n{output[-800:]}"


def handle_validate(message: str) -> str:
    """Run validate_claim.py on the challenged-zone claims."""
    rc, output = _run_script("validate_claim.py")
    try:
        data    = json.loads(output.split("\n[stderr]")[0].strip())
        count   = data.get("validated_count", 0)
        results = data.get("results", [])
        lines   = [f"Validated {count} claim(s) in confidence range 0.45–0.55:"]
        for r in results[:5]:
            adj  = r.get("suggested_magnitude", 0.0)
            sign = "+" if adj >= 0 else ""
            lines.append(
                f"  {r['claim_id'][:8]}... | {r['verdict']} | "
                f"conf {r['current_confidence']:.2f} → {r['suggested_new_confidence']:.2f} ({sign}{adj})"
            )
        return "\n".join(lines)
    except Exception:
        return f"Validate output (exit {rc}):\n{output[-800:]}"


def handle_boundary_audit(message: str) -> str:
    """Run boundary_audit.py."""
    rc, output = _run_script("boundary_audit.py", ["--recent", "20"])
    try:
        data       = json.loads(output.split("\n[stderr]")[0].strip())
        alert      = data.get("alert", "UNKNOWN")
        audited    = data.get("claims_audited", 0)
        violations = data.get("violation_count", 0)
        prefix = "⚠️ " if violations else "✅"
        return (
            f"{prefix} Boundary audit: {alert}\n"
            f"   Claims audited : {audited}\n"
            f"   Violations     : {violations}\n"
        )
    except Exception:
        return f"Boundary audit output (exit {rc}):\n{output[-800:]}"


def handle_evidence_search(message: str) -> str:
    """Run evidence_search.py to find gaps."""
    rc, output = _run_script("evidence_search.py")
    try:
        data    = json.loads(output.split("\n[stderr]")[0].strip())
        summary = data.get("summary", {})
        return (
            f"🔍 Gap detection complete:\n"
            f"   Orphan claims        : {summary.get('orphan_count', 0)}\n"
            f"   Sparse domains       : {summary.get('sparse_domain_count', 0)}\n"
            f"   Unchallenged insights: {summary.get('unchallenged_insight_count', 0)}\n"
            f"   Report: memory/gaps-{datetime.utcnow().strftime('%Y-%m-%d')}.md"
        )
    except Exception:
        return f"Evidence search output (exit {rc}):\n{output[-800:]}"


def handle_rechallenge(message: str) -> str:
    """Run adversarial_rechallenge.py."""
    rc, output = _run_script("adversarial_rechallenge.py")
    try:
        data = json.loads(output.split("\n[stderr]")[0].strip())
        if data.get("status") == "rate_limited":
            return f"⏳ Re-challenge rate limited. Next eligible: {data.get('next_eligible', '?')}"
        if data.get("status") == "no_candidates":
            return "No high-confidence insights (>0.75) available for re-challenge."
        category   = data.get("verdict", {}).get("category", "?")
        insight_id = data.get("insight_id", "?")[:8]
        prior_conf = data.get("prior_confidence", 0)
        new_conf   = data.get("new_confidence_estimate", 0)
        return (
            f"🔬 Re-challenge complete:\n"
            f"   Insight   : {insight_id}...\n"
            f"   Category  : {category}\n"
            f"   Confidence: {prior_conf:.2f} → {new_conf:.2f}\n"
        )
    except Exception:
        return f"Re-challenge output (exit {rc}):\n{output[-800:]}"


# ── Dispatch table ───────────────────────────────────────────────────────────

TRIGGERS = {
    # Pipeline
    "run council":             handle_run_pipeline,
    "run pipeline":            handle_run_pipeline,
    "run the council":         handle_run_pipeline,
    "start pipeline":          handle_run_pipeline,
    # Status
    "substrate status":        handle_status,
    "council status":          handle_status,
    "epistemic status":        handle_status,
    "show status":             handle_status,
    # Health
    "health check":            handle_health_check,
    "check health":            handle_health_check,
    # Analysis
    "analyze cross-domain":    handle_evidence_search,
    "find analogies":          handle_evidence_search,
    "find gaps":               handle_evidence_search,
    "structural similarities": handle_evidence_search,
    "cross-domain patterns":   handle_evidence_search,
    "epistemic reasoning":     handle_run_pipeline,
    # Validation
    "validate claims":         handle_validate,
    "validate claim":          handle_validate,
    # Boundary
    "check boundaries":        handle_boundary_audit,
    "boundary audit":          handle_boundary_audit,
    # Re-challenge
    "rechallenge":             handle_rechallenge,
    "re-challenge":            handle_rechallenge,
    "adversarial rechallenge": handle_rechallenge,
}


def dispatch(message: str) -> str:
    """Entry point called by OpenClaw skill runner."""
    msg_lower = message.lower().strip()

    for trigger, handler in TRIGGERS.items():
        if trigger in msg_lower:
            try:
                return handler(message)
            except Exception as e:
                log.error(f"Handler error for '{trigger}': {e}", exc_info=True)
                return f"Skill error in '{trigger}': {e}"

    return (
        "🔬 Epistemic Council skill active.\n\n"
        "Commands:\n"
        "  'run council'        — full pipeline run (claims → insights → validation)\n"
        "  'substrate status'   — show event counts + last run summary\n"
        "  'health check'       — scan for high-risk claims and orphans\n"
        "  'validate claims'    — re-evaluate challenged-zone claims\n"
        "  'check boundaries'   — scan for domain boundary violations\n"
        "  'find gaps'          — detect orphan claims and sparse coverage\n"
        "  'rechallenge'        — re-challenge highest-confidence insight\n"
        "\nTo install new skills: use OpenClaw's native skill commands\n"
        "  'openclaw skill install <name>' or ask the agent to install a skill by name\n"
    )


if __name__ == "__main__":
    query = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "substrate status"
    print(dispatch(query))


path: evidence_search.py
content: #!/usr/bin/env python3
"""
openclaw_tasks/evidence_search.py
Task 2 — Gap Detection & Evidence Discovery

Phase 3 scope: LOCAL SUBSTRATE ONLY.
No external web calls (OpenClaw skill not yet registered; network policy unconfirmed).
External search deferred to Phase 4.

Finds:
  - Orphan claims (no parents, no children, no linked evidence)
  - Claims with sparse domain coverage (< SPARSE_THRESHOLD per domain)
  - Claims that are semantically isolated (no cross-references in parent_ids)

Writes gap report to memory/gaps-YYYY-MM-DD.md
Proposed new claims written as CLAIM_CREATED events with agent_id='monitoring_agent'
and confidence=0.15 (low — requires human verification before any weight is given).

Safety: writes are append-only via validated substrate write_claim().
        No claim can be auto-verified by this script.

Usage:
  python evidence_search.py [db_path]
  python evidence_search.py [db_path] --claim-id <uuid>
"""

import sys
import json
import os
from datetime import datetime
from pathlib import Path
from collections import defaultdict

WORKSPACE = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(WORKSPACE))

from substrate import Substrate, EventType

SPARSE_THRESHOLD = 3          # domain has "sparse" coverage if < 3 claims
ORPHAN_MIN_AGE_HOURS = 1      # only flag orphans older than this (avoid race)


def detect_gaps(substrate: Substrate) -> dict:
    total = substrate.event_count()
    if total < 5:
        return {"status": "insufficient_data", "total_events": total}

    # ── 1. Orphan detection ──────────────────────────────────────────
    no_evidence = substrate.get_claims_without_evidence()
    orphans = []
    for claim in no_evidence:
        # No parents AND no children = true orphan
        if not claim.parent_ids and substrate.count_children(claim.event_id) == 0:
            # Only flag claims older than ORPHAN_MIN_AGE_HOURS
            try:
                age_hours = (
                    datetime.utcnow() -
                    datetime.fromisoformat(claim.timestamp.replace("Z", ""))
                ).total_seconds() / 3600
            except Exception:
                age_hours = 999  # assume old if unparseable

            if age_hours >= ORPHAN_MIN_AGE_HOURS:
                orphans.append({
                    "event_id": claim.event_id,
                    "domain": claim.domain,
                    "confidence": claim.confidence,
                    "age_hours": round(age_hours, 1),
                    "claim_snippet": str(claim.content.get("claim", ""))[:120],
                })

    # ── 2. Sparse domain coverage ────────────────────────────────────
    all_claims = substrate.get_events_by_type(EventType.CLAIM_CREATED)
    domain_counts = defaultdict(int)
    for c in all_claims:
        domain_counts[c.domain] += 1

    sparse_domains = [
        {"domain": d, "claim_count": n}
        for d, n in domain_counts.items()
        if n < SPARSE_THRESHOLD
    ]

    # ── 3. Claims lacking any cross-reference ────────────────────────
    isolated = []
    for claim in all_claims:
        # A claim is isolated if it appears in no other event's parent_ids
        child_count = substrate.count_children(claim.event_id)
        if child_count == 0 and not claim.parent_ids:
            isolated.append({
                "event_id": claim.event_id,
                "domain": claim.domain,
                "confidence": claim.confidence,
            })

    # ── 4. Insights with no challenge history ────────────────────────
    insights = substrate.get_insights()
    unchallenged_insights = []
    for ins in insights:
        challenges = substrate.get_challenge_results_for_insight(ins.event_id)
        if not challenges:
            unchallenged_insights.append({
                "event_id": ins.event_id,
                "insight_type": ins.content.get("insight_type", "?"),
                "confidence": ins.confidence,
            })

    return {
        "status": "ok",
        "timestamp": datetime.utcnow().isoformat(),
        "total_events": total,
        "orphan_claims": orphans,
        "sparse_domains": sparse_domains,
        "isolated_claims_count": len(isolated),
        "unchallenged_insights": unchallenged_insights,
        "summary": {
            "orphan_count": len(orphans),
            "sparse_domain_count": len(sparse_domains),
            "unchallenged_insight_count": len(unchallenged_insights),
        }
    }


def write_gap_report(gaps: dict, workspace: Path):
    """Write human-readable gap report to memory/gaps-YYYY-MM-DD.md"""
    memory_dir = workspace / "memory"
    memory_dir.mkdir(parents=True, exist_ok=True)
    date_str = datetime.utcnow().strftime("%Y-%m-%d")
    report_path = memory_dir / f"gaps-{date_str}.md"

    lines = [
        f"# Gap Detection Report — {date_str}",
        f"Generated: {gaps.get('timestamp', 'unknown')}",
        f"Total substrate events: {gaps.get('total_events', 0)}",
        "",
        "## Orphan Claims",
        f"Count: {len(gaps.get('orphan_claims', []))}",
        "",
    ]
    for orphan in gaps.get("orphan_claims", [])[:10]:
        lines.append(
            f"- `{orphan['event_id'][:8]}...` | {orphan['domain']} | "
            f"conf={orphan['confidence']:.2f} | age={orphan['age_hours']}h"
        )
        lines.append(f"  > {orphan['claim_snippet']}")

    lines += [
        "",
        "## Sparse Domain Coverage",
        f"Domains with < {SPARSE_THRESHOLD} claims:",
        "",
    ]
    for sd in gaps.get("sparse_domains", []):
        lines.append(f"- {sd['domain']}: {sd['claim_count']} claim(s)")

    lines += [
        "",
        "## Unchallenged Insights",
        f"Count: {len(gaps.get('unchallenged_insights', []))}",
        "",
    ]
    for ins in gaps.get("unchallenged_insights", [])[:5]:
        lines.append(
            f"- `{ins['event_id'][:8]}...` | {ins['insight_type']}-type | "
            f"conf={ins['confidence']:.2f}"
        )

    lines += [
        "",
        "## Action Required",
        "- Orphans: consider linking to related claims or flagging for human review",
        "- Sparse domains: schedule additional pipeline runs for underrepresented domains",
        "- Unchallenged insights: add to adversarial_rechallenge.py next cycle",
        "",
        "---",
        "_Generated by evidence_search.py (local substrate only — Phase 3)_",
        "_External evidence search deferred to Phase 4_",
    ]

    report_path.write_text("\n".join(lines))

    # Also log to openclaw-runs
    runs_dir = workspace / "memory" / "openclaw-runs"
    runs_dir.mkdir(parents=True, exist_ok=True)
    log_file = runs_dir / f"{date_str}.json"
    existing = []
    if log_file.exists():
        try:
            existing = json.loads(log_file.read_text())
        except Exception:
            pass
    existing.append({"task": "evidence_search", **gaps})
    log_file.write_text(json.dumps(existing, indent=2))

    return report_path


def main():
    db_path = str(WORKSPACE / "epistemic.db")
    if len(sys.argv) > 1 and not sys.argv[1].startswith("--"):
        db_path = sys.argv[1]

    substrate = Substrate(db_path)
    gaps = detect_gaps(substrate)
    substrate.close()

    report_path = write_gap_report(gaps, WORKSPACE)
    print(json.dumps(gaps, indent=2))
    print(f"\n[evidence_search] Gap report written to {report_path}", file=sys.stderr)


if __name__ == "__main__":
    main()

path: heartbeat-state.json
content: {
  "last_pipeline_run": null,
  "last_query": null,
  "query_rotation_index": 0,
  "pipeline_running": false,
  "last_monitoring_run": null,
  "last_rechallenge_utc": null,
  "last_rechallenge_insight_id": null,
  "last_skill_check": null,
  "last_run_summary": null,
  "lastChecks": {
    "substrate_health": null,
    "pipeline": null,
    "monitoring": null,
    "rechallenge": null,
    "memory_compress": null,
    "skill_download": null
  }
}


path: memory_compress.py
content: #!/usr/bin/env python3
"""
openclaw_tasks/memory_compress.py
Task 6 — Memory Maintenance & Compression

Runs weekly (Monday 03:00 per HEARTBEAT.md).
Reads last 7 days of memory/YYYY-MM-DD.md files.
Extracts calibration lessons and domain edge cases via Ollama.
Updates MEMORY.md with distilled insights.
Archives old daily logs to memory/archive/.

Also performs a calibration consistency check:
  Compares LearningSystem heuristics against actual substrate outcomes
  and flags drift if they diverge by > DRIFT_THRESHOLD.

Usage:
  python memory_compress.py [db_path] [--days N] [--dry-run]
"""

import sys
import json
import os
import http.client
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from glob import glob

WORKSPACE = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(WORKSPACE))

from substrate import Substrate, EventType

MEMORY_DIR = WORKSPACE / "memory"
ARCHIVE_DIR = MEMORY_DIR / "archive"
MEMORY_MD = MEMORY_DIR / "MEMORY.md"
OLLAMA_HOST = "localhost"
OLLAMA_PORT = 11434
OLLAMA_MODEL = "glm-4.7-flash"
DRIFT_THRESHOLD = 0.15  # flag if calibration heuristics drift by more than this


COMPRESS_PROMPT = """You are an epistemic calibration analyst. Below are daily session logs from an Epistemic Council agent over the past {n_days} day(s).

DAILY LOGS:
{log_text}

Extract the following from these logs:
1. CALIBRATION LESSONS — cases where confidence estimates were wrong (too high or too low). Format: "Domain X claims with evidence pattern Y: adjust base confidence from A to B"
2. DOMAIN BOUNDARIES DISCOVERED — edge cases where claims were near or over domain boundaries
3. EVIDENCE SOURCE QUALITY UPDATES — any sources found to be more or less reliable than expected
4. RECURRING PATTERNS — repeated failure modes or success patterns

Respond in this exact format (each section may have 0 to 5 bullet items):

CALIBRATION_LESSONS:
- [lesson 1]
- [lesson 2]

DOMAIN_BOUNDARIES:
- [boundary finding 1]

EVIDENCE_QUALITY:
- [quality update 1]

RECURRING_PATTERNS:
- [pattern 1]
"""


def call_ollama(prompt: str) -> str:
    try:
        payload = json.dumps({
            "model": OLLAMA_MODEL,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": 0.1},
        }).encode()
        conn = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=60)
        conn.request("POST", "/api/generate", payload, {"Content-Type": "application/json"})
        resp = conn.getresponse()
        if resp.status != 200:
            return f"[Ollama unavailable: HTTP {resp.status}]"
        data = json.loads(resp.read())
        return data.get("response", "").strip()
    except Exception as e:
        return f"[Ollama unavailable: {e}]"


def parse_compression_response(text: str) -> dict:
    """Parse Ollama's structured compression output into sections."""
    sections = {
        "calibration_lessons": [],
        "domain_boundaries": [],
        "evidence_quality": [],
        "recurring_patterns": [],
    }
    section_map = {
        "CALIBRATION_LESSONS:": "calibration_lessons",
        "DOMAIN_BOUNDARIES:": "domain_boundaries",
        "EVIDENCE_QUALITY:": "evidence_quality",
        "RECURRING_PATTERNS:": "recurring_patterns",
    }
    current = None
    for line in text.splitlines():
        line = line.strip()
        for header, key in section_map.items():
            if line.startswith(header):
                current = key
                break
        else:
            if current and line.startswith("- "):
                sections[current].append(line[2:])
    return sections


def read_daily_logs(n_days: int = 7) -> tuple:
    """Returns (combined_text, list_of_paths_read)."""
    cutoff = datetime.utcnow() - timedelta(days=n_days)
    logs = []
    paths = []
    for day_offset in range(n_days):
        date = (datetime.utcnow() - timedelta(days=day_offset)).strftime("%Y-%m-%d")
        log_file = MEMORY_DIR / f"{date}.md"
        if log_file.exists():
            logs.append(f"=== {date} ===\n{log_file.read_text()[:2000]}")
            paths.append(log_file)
    return "\n\n".join(logs), paths


def archive_old_logs(paths: list, dry_run: bool = False):
    """Move daily logs older than 7 days to memory/archive/."""
    cutoff = datetime.utcnow() - timedelta(days=7)
    ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
    archived = []
    for path in paths:
        try:
            # Parse date from filename
            date_str = path.stem  # YYYY-MM-DD
            file_date = datetime.strptime(date_str, "%Y-%m-%d")
            if file_date < cutoff:
                dest = ARCHIVE_DIR / path.name
                if not dry_run:
                    shutil.move(str(path), str(dest))
                archived.append(path.name)
        except Exception:
            continue
    return archived


def update_memory_md(sections: dict, substrate: Substrate):
    """
    Rewrite MEMORY.md with current distilled insights.
    Appends new content to existing sections rather than overwriting.
    """
    existing = MEMORY_MD.read_text() if MEMORY_MD.exists() else ""

    # Compute substrate-level calibration reality check
    insights = substrate.get_insights()
    total_insights = len(insights)
    high_conf = substrate.get_insights_above_confidence(0.75)
    challenged_zone = substrate.get_claims_in_confidence_range(0.45, 0.55)

    now = datetime.utcnow().isoformat()

    new_content = f"""# MEMORY.md — Epistemic Council Agent Long-Term Memory
_Last updated: {now}_

## Substrate Reality (auto-computed)
- Total insights in substrate: {total_insights}
- High-confidence insights (>0.75): {len(high_conf)}
- Challenged-zone claims (0.45–0.55): {len(challenged_zone)}

## Calibration Heuristics
_Updated from last 7-day log compression_
"""
    for lesson in sections.get("calibration_lessons", []):
        new_content += f"- {lesson}\n"
    if not sections.get("calibration_lessons"):
        new_content += "- No calibration events recorded in this window\n"

    new_content += "\n## Domain Boundaries Discovered\n"
    for boundary in sections.get("domain_boundaries", []):
        new_content += f"- {boundary}\n"
    if not sections.get("domain_boundaries"):
        new_content += "- No boundary edge cases in this window\n"

    new_content += "\n## Evidence Source Quality\n"
    for quality in sections.get("evidence_quality", []):
        new_content += f"- {quality}\n"
    if not sections.get("evidence_quality"):
        new_content += "- No source quality updates in this window\n"

    new_content += "\n## Recurring Patterns\n"
    for pattern in sections.get("recurring_patterns", []):
        new_content += f"- {pattern}\n"
    if not sections.get("recurring_patterns"):
        new_content += "- No recurring patterns identified\n"

    new_content += "\n## User Preferences\n_(To be filled after first conversation)_\n"

    MEMORY_MD.write_text(new_content)


def run_drift_check(substrate: Substrate) -> dict:
    """
    Compare LearningSystem calibration state against substrate actuals.
    Flags if drift > DRIFT_THRESHOLD.
    """
    drift_report = {"status": "learning_system_unavailable", "drift": None}
    try:
        from self_improvement import LearningSystem
        ls = LearningSystem()
        # LearningSystem stores confidence adjustments; compare against actual avg
        actual_insights = substrate.get_insights()
        if not actual_insights:
            return {"status": "no_insights", "drift": None}

        actual_avg = sum(i.confidence for i in actual_insights) / len(actual_insights)
        stored_avg = getattr(ls, "average_confidence", None)

        if stored_avg is not None:
            drift = abs(actual_avg - stored_avg)
            drift_report = {
                "status": "ok",
                "actual_avg_confidence": round(actual_avg, 4),
                "learning_system_avg": round(stored_avg, 4),
                "drift": round(drift, 4),
                "alert": "DRIFT_DETECTED" if drift > DRIFT_THRESHOLD else "OK",
            }
        else:
            drift_report = {"status": "learning_system_no_avg_stored", "drift": None}
    except ImportError:
        pass
    except Exception as e:
        drift_report = {"status": "error", "error": str(e), "drift": None}
    return drift_report


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("db_path", nargs="?", default=str(WORKSPACE / "epistemic.db"))
    parser.add_argument("--days", type=int, default=7)
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    MEMORY_DIR.mkdir(parents=True, exist_ok=True)
    ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)

    substrate = Substrate(args.db_path)
    date_str = datetime.utcnow().strftime("%Y-%m-%d")

    # Read daily logs
    log_text, log_paths = read_daily_logs(args.days)

    if not log_text.strip():
        result = {
            "status": "no_logs_found",
            "days": args.days,
            "timestamp": datetime.utcnow().isoformat(),
        }
        print(json.dumps(result, indent=2))
        substrate.close()
        return

    print(f"[memory_compress] Compressing {len(log_paths)} day(s) of logs...", file=sys.stderr)

    # Call Ollama for compression
    prompt = COMPRESS_PROMPT.format(n_days=args.days, log_text=log_text[:6000])
    raw_response = call_ollama(prompt)
    sections = parse_compression_response(raw_response)

    # Update MEMORY.md
    if not args.dry_run:
        update_memory_md(sections, substrate)

    # Archive old logs
    archived = archive_old_logs(log_paths, dry_run=args.dry_run)

    # Drift check
    drift = run_drift_check(substrate)

    substrate.close()

    result = {
        "status": "ok",
        "timestamp": datetime.utcnow().isoformat(),
        "days_compressed": args.days,
        "logs_read": len(log_paths),
        "logs_archived": archived,
        "sections_extracted": {k: len(v) for k, v in sections.items()},
        "drift_check": drift,
        "dry_run": args.dry_run,
    }

    # Write to openclaw-runs
    if not args.dry_run:
        runs_dir = MEMORY_DIR / "openclaw-runs"
        runs_dir.mkdir(parents=True, exist_ok=True)
        log_file = runs_dir / f"{date_str}.json"
        existing = []
        if log_file.exists():
            try:
                existing = json.loads(log_file.read_text())
            except Exception:
                pass
        existing.append({"task": "memory_compress", **result})
        log_file.write_text(json.dumps(existing, indent=2))

    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()

path: metrics.py
content: """
metrics.py — Epistemic Council v2.0
Pipeline metrics reporting.

FIX (Gap Analysis §3.4):
  Changed all event_type string comparisons to use EventType enum values.
  Previously: e.event_type == 'insight_generated'  → silently matched nothing
  Fixed:      e.event_type == EventType.INSIGHT_GENERATED
"""

from dataclasses import dataclass, field
from typing import List, Dict, Any
from substrate import Substrate, EventType, SubstrateEvent


@dataclass
class PipelineMetrics:
    total_events: int = 0
    claims_created: int = 0
    insights_generated: int = 0
    challenge_results: int = 0
    adversarial_challenges: int = 0
    evidence_linked: int = 0
    human_reviews: int = 0
    hypothesis_forks: int = 0
    integrity_check: Dict[str, Any] = field(default_factory=dict)
    domain_breakdown: Dict[str, int] = field(default_factory=dict)
    insight_type_breakdown: Dict[str, int] = field(default_factory=dict)
    avg_confidence: float = 0.0
    high_confidence_insights: int = 0  # > 0.75
    low_confidence_claims: int = 0    # < 0.45
    challenged_zone_claims: int = 0   # 0.45–0.55


def compute_metrics(substrate: Substrate) -> PipelineMetrics:
    """
    Compute full pipeline metrics from substrate.
    All comparisons use EventType enum (Gap §3.4 fix).
    """
    events = substrate.get_all_events()
    m = PipelineMetrics()
    m.total_events = len(events)

    confidence_sum = 0.0
    confidence_count = 0

    for e in events:
        # --- FIX: use EventType enum, not strings ---
        if e.event_type == EventType.CLAIM_CREATED:
            m.claims_created += 1
            m.domain_breakdown[e.domain] = m.domain_breakdown.get(e.domain, 0) + 1
            confidence_sum += e.confidence
            confidence_count += 1
            if e.confidence < 0.45:
                m.low_confidence_claims += 1
            if 0.45 <= e.confidence <= 0.55:
                m.challenged_zone_claims += 1

        elif e.event_type == EventType.INSIGHT_GENERATED:
            m.insights_generated += 1
            insight_type = e.content.get("insight_type", "unknown")
            m.insight_type_breakdown[insight_type] = (
                m.insight_type_breakdown.get(insight_type, 0) + 1
            )
            confidence_sum += e.confidence
            confidence_count += 1
            if e.confidence > 0.75:
                m.high_confidence_insights += 1

        elif e.event_type == EventType.CHALLENGE_RESULT:
            m.challenge_results += 1

        elif e.event_type == EventType.ADVERSARIAL_CHALLENGE:
            m.adversarial_challenges += 1

        elif e.event_type == EventType.EVIDENCE_LINKED:
            m.evidence_linked += 1

        elif e.event_type == EventType.HUMAN_REVIEW:
            m.human_reviews += 1

        elif e.event_type == EventType.HYPOTHESIS_FORKED:
            m.hypothesis_forks += 1

    m.avg_confidence = (
        round(confidence_sum / confidence_count, 4) if confidence_count > 0 else 0.0
    )
    m.integrity_check = substrate.verify_all()
    return m


def print_metrics(m: PipelineMetrics):
    print("\n" + "=" * 55)
    print("  EPISTEMIC COUNCIL — PIPELINE METRICS")
    print("=" * 55)
    print(f"  Total events         : {m.total_events}")
    print(f"  Claims created       : {m.claims_created}")
    print(f"  Insights generated   : {m.insights_generated}")
    print(f"  Challenge results    : {m.challenge_results}")
    print(f"  Evidence linked      : {m.evidence_linked}")
    print(f"  Human reviews        : {m.human_reviews}")
    print(f"  Hypothesis forks     : {m.hypothesis_forks}")
    print(f"  Avg confidence       : {m.avg_confidence}")
    print(f"  High-confidence (>0.75): {m.high_confidence_insights}")
    print(f"  Challenged zone claims : {m.challenged_zone_claims}")
    print(f"  Low-confidence (<0.45) : {m.low_confidence_claims}")
    print()
    print("  Domain breakdown:")
    for domain, count in m.domain_breakdown.items():
        print(f"    {domain}: {count}")
    print()
    print("  Insight type breakdown:")
    for itype, count in m.insight_type_breakdown.items():
        print(f"    {itype}-type: {count}")
    print()
    integrity = m.integrity_check
    print(f"  Integrity: {integrity.get('passed', 0)}/{integrity.get('total', 0)} passed")
    if integrity.get("failed"):
        print(f"  FAILED event IDs: {integrity['failed']}")
    print("=" * 55 + "\n")

path: run.py
content: """
epistemic_council/run.py

The entry point. Orchestrates the full pipeline:

  1. Both domain agents reason on a shared query
  2. Claims land in the substrate
  3. Detection engine scans for cross-domain analogies
  4. Adversarial validation challenges insights
  5. Results are printed for human review

Usage:
    python run.py

Prerequisites:
    ollama serve                    # Ollama must be running
    ollama pull glm-4.7-flash         # Model must be pulled
    pip install requests            # Only external dependency
"""

import os
import sys
from pathlib import Path

from substrate import Substrate
from agent import DomainAgent, ModelClient
from detection import DetectionEngine, DetectionConfig
from challenge_orchestrator import ChallengeOrchestrator


# ---------------------------------------------------------------------------
# Query bank
# ---------------------------------------------------------------------------

QUERIES = [
    {
        "id": "optimization_under_constraints",
        "text": (
            "How do systems find good solutions to complex optimization problems "
            "when they cannot evaluate every possible option? Focus on strategies "
            "that work well in large, uncertain search spaces."
        ),
    },
    {
        "id": "emergent_collective_behavior",
        "text": (
            "How do large groups of simple agents produce intelligent collective "
            "behavior without any central coordinator? What makes some collective "
            "strategies robust and others fragile?"
        ),
    },
    {
        "id": "adaptation_to_changing_environment",
        "text": (
            "How do systems adapt their strategies when the environment they "
            "operate in changes over time? What makes adaptation fast versus slow, "
            "and what are the tradeoffs involved?"
        ),
    },
]


# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------

def run(query_id: str = "optimization_under_constraints", db_path: str = "epistemic.db"):
    """
    Execute one full cycle of the epistemic council loop.

    Args:
        query_id: Which query from QUERIES to use
        db_path:  Path to the SQLite substrate file
    """
    # Find the query
    query = next((q for q in QUERIES if q["id"] == query_id), None)
    if query is None:
        print(f"❌ Unknown query_id: {query_id}")
        print(f"   Available: {[q['id'] for q in QUERIES]}")
        return

    print("=" * 70)
    print("  EPISTEMIC COUNCIL — PHASE 2 RUN")
    print("=" * 70)
    print(f"\n📌 Query: {query['id']}")
    print(f"   {query['text']}\n")

    # --- Substrate ---------------------------------------------------------
    print("─" * 70)
    print("  PHASE 1: SUBSTRATE")
    print("─" * 70)
    substrate = Substrate(db_path)
    print(f"✅ Substrate initialized: {db_path} ({substrate.event_count()} existing events)\n")

    # --- Model client ------------------------------------------------------
    print("─" * 70)
    print("  PHASE 2: MODEL")
    print("─" * 70)
    model_client = ModelClient("glm-4.7-flash")
    print()

    # --- Domain agents -----------------------------------------------------
    print("─" * 70)
    print("  PHASE 3: DOMAIN AGENTS")
    print("─" * 70)

    cs_agent = DomainAgent("computer_science", substrate, model_client)
    bio_agent = DomainAgent("biology", substrate, model_client)
    print()

    # CS agent reasons first
    print(f"── {cs_agent.domain.name} ──")
    cs_claim_ids = cs_agent.reason(query["text"])
    print()

    # Then biology
    print(f"── {bio_agent.domain.name} ──")
    bio_claim_ids = bio_agent.reason(query["text"])
    print()

    # --- Detection ---------------------------------------------------------
    print("─" * 70)
    print("  PHASE 4: CROSS-DOMAIN DETECTION")
    print("─" * 70)

    engine = DetectionEngine(substrate, model_client)
    insight_ids = engine.detect("computer_science", "biology")
    print()

    # --- Adversarial Validation --------------------------------------------
    print("─" * 70)
    print("  PHASE 5: ADVERSARIAL VALIDATION")
    print("─" * 70)

    if not insight_ids:
        print("⚠️  No insights to validate.\n")
        validated_insights = []
        challenged_insights = []
        rejected_insights = []
    else:
        orchestrator = ChallengeOrchestrator(substrate, model_client.base_url)
        
        # Get insight events
        insight_events = [substrate.get_event(iid) for iid in insight_ids]
        
        # Run validation
        validated_insights, challenged_insights, rejected_insights = orchestrator.validate_batch(
            insight_events, query["text"]
        )
    print()

    # --- Summary -----------------------------------------------------------
    print("─" * 70)
    print("  PHASE 6: RESULTS")
    print("─" * 70)

    print(f"\n📊 Substrate state:")
    print(f"   Total events:      {substrate.event_count()}")
    print(f"   CS claims:         {len(cs_claim_ids)}")
    print(f"   Biology claims:    {len(bio_claim_ids)}")
    print(f"   Insights found:    {len(insight_ids)}")
    print(f"   Validated:         {len(validated_insights)}")
    print(f"   Challenged:        {len(challenged_insights)}")
    print(f"   Rejected:          {len(rejected_insights)}\n")

    if not insight_ids:
        print("⚠️  No insights were written. Either:")
        print("   - The model didn't find structural analogies")
        print("   - Confidence scores fell below threshold after penalty")
        print("   - The query didn't produce claims with enough overlap")
        print("\n   Try a different query or adjust DetectionConfig thresholds.")
    else:
        # Show validated insights
        if validated_insights:
            print("✅ VALIDATED INSIGHTS:\n")
            for i, (insight, result) in enumerate(validated_insights, 1):
                print(f"  [{i}] (confidence: {result['original_confidence']:.2f} → {result['final_confidence']:.2f})")
                print(f"      {insight.content['text'][:100]}...")
                print(f"      Passed: {result['passes']}/3 challenges")
                print(f"      Type: {insight.content['insight_type']}")
                print(f"      Source claims: {len(insight.parent_ids)}")
                print()
        
        # Show challenged insights needing review
        if challenged_insights:
            print("⚠️  CHALLENGED INSIGHTS (need human review):\n")
            for i, (insight, result) in enumerate(challenged_insights, 1):
                print(f"  [{i}] (confidence: {result['original_confidence']:.2f} → {result['final_confidence']:.2f})")
                print(f"      {insight.content['text'][:100]}...")
                print(f"      Passed: {result['passes']}/3 challenges")
                print()
        
        # Show rejected insights
        if rejected_insights:
            print("❌ REJECTED INSIGHTS:\n")
            for i, (insight, result) in enumerate(rejected_insights, 1):
                print(f"  [{i}] (confidence: {result['original_confidence']:.2f} → {result['final_confidence']:.2f})")
                print(f"      {insight.content['text'][:100]}...")
                print(f"      Failed: {3 - result['passes']}/3 challenges")
                print()

    # --- Integrity check ---------------------------------------------------
    print("─" * 70)
    print("  INTEGRITY CHECK")
    print("─" * 70)
    verification = substrate.verify_all()
    failed_list = verification.get("failed", [])
    status = "✅ PASSED" if not failed_list else "❌ FAILED"
    print(f"   {status} — {verification['passed']}/{verification['total']} events verified")
    if failed_list:
        print(f"   Failed: {failed_list}")

    print("\n" + "=" * 70)
    print("  RUN COMPLETE")
    print("=" * 70 + "\n")


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

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Epistemic Council — Phase 2 Run")
    parser.add_argument(
        "--query", "-q",
        default="optimization_under_constraints",
        choices=[q["id"] for q in QUERIES],
        help="Which query to run (default: optimization_under_constraints)",
    )
    parser.add_argument(
        "--db", "-d",
        default="epistemic.db",
        help="Path to substrate database (default: epistemic.db)",
    )
    parser.add_argument(
        "--list-queries",
        action="store_true",
        help="Print available queries and exit",
    )

    args = parser.parse_args()

    if args.list_queries:
        print("\nAvailable queries:\n")
        for q in QUERIES:
            print(f"  {q['id']}")
            print(f"    {q['text']}\n")
        sys.exit(0)

    run(query_id=args.query, db_path=args.db)


path: self_improvement.py
content: """
Self-Improvement Layer for Epistemic Council
Agent learns from adversarial validation feedback
"""

import json
from datetime import datetime
from typing import Dict, List, Tuple
from collections import defaultdict


class LearningSystem:
    """
    Tracks what works and what fails, adjusts agent behavior
    """
    
    def __init__(self, learning_file='agent_learning.json'):
        self.learning_file = learning_file
        self.data = self.load()
        
    def load(self) -> Dict:
        """Load learning data"""
        try:
            with open(self.learning_file, 'r') as f:
                return json.load(f)
        except FileNotFoundError:
            return self._init_learning()
            
    def _init_learning(self) -> Dict:
        """Initialize learning structure"""
        return {
            'version': '1.0',
            'started': datetime.now().isoformat(),
            'claim_patterns': {
                'successful': [],  # Claims that led to validated insights
                'failed': []       # Claims that led to rejected insights
            },
            'insight_patterns': {
                'validated': [],   # Patterns in validated insights
                'rejected': []     # Patterns in rejected insights
            },
            'adversarial_feedback': {
                'boundary_violations': [],
                'null_hypothesis_fails': [],
                'false_analogies': []
            },
            'confidence_calibration': {
                'overconfident': [],  # High conf but rejected
                'underconfident': []  # Low conf but validated
            },
            'domain_performance': {
                'computer_science': {'success_rate': 0, 'total': 0},
                'biology': {'success_rate': 0, 'total': 0}
            },
            'improvement_metrics': {
                'validation_rate_over_time': [],
                'confidence_accuracy_over_time': []
            }
        }
        
    def save(self):
        """Save learning data"""
        with open(self.learning_file, 'w') as f:
            json.dump(self.data, f, indent=2)
            
    def record_result(self, insight: Dict, validation: Dict, claims: List[Dict]):
        """
        Learn from validation result
        
        Args:
            insight: The insight that was validated
            validation: Validation results (passes, status, details)
            claims: Source claims that generated this insight
        """
        status = validation['status']
        
        # Track claim patterns
        for claim in claims:
            pattern = self._extract_claim_pattern(claim)
            
            if status == 'validated':
                self.data['claim_patterns']['successful'].append(pattern)
            elif status == 'rejected':
                self.data['claim_patterns']['failed'].append(pattern)
                
        # Track insight patterns
        insight_pattern = self._extract_insight_pattern(insight)
        
        if status == 'validated':
            self.data['insight_patterns']['validated'].append(insight_pattern)
        else:
            self.data['insight_patterns']['rejected'].append(insight_pattern)
            
        # Learn from adversarial feedback
        self._learn_from_challenges(validation['results'])
        
        # Track confidence calibration
        self._track_confidence(insight, validation)
        
        # Update domain performance
        self._update_domain_stats(claims, status)
        
        self.save()
        
    def _extract_claim_pattern(self, claim: Dict) -> Dict:
        """Extract learnable pattern from claim"""
        return {
            'domain': claim.get('domain'),
            'confidence': claim.get('confidence'),
            'length': len(claim.get('text', '')),
            'has_specifics': any(word in claim.get('text', '').lower() 
                                for word in ['algorithm', 'mechanism', 'process', 'strategy']),
            'timestamp': datetime.now().isoformat()
        }
        
    def _extract_insight_pattern(self, insight: Dict) -> Dict:
        """Extract learnable pattern from insight"""
        return {
            'type': insight.get('insight_type'),
            'confidence': insight.get('confidence'),
            'source_count': len(insight.get('source_claim_ids', [])),
            'length': len(insight.get('text', '')),
            'has_both_domains': 'computer' in insight.get('text', '').lower() and 'biolog' in insight.get('text', '').lower(),
            'timestamp': datetime.now().isoformat()
        }
        
    def _learn_from_challenges(self, challenge_results: Dict):
        """Learn from adversarial validation"""
        # Guard: challenge_results may be empty (e.g. from rechallenge path)
        if not challenge_results:
            return

        boundary = challenge_results.get('boundary')
        if boundary and hasattr(boundary, 'passed') and not boundary.passed:
            for violation in boundary.details.get('violations', []):
                self.data['adversarial_feedback']['boundary_violations'].append({
                    'term': violation.get('found_terms', []),
                    'domain': violation.get('domain'),
                    'timestamp': datetime.now().isoformat()
                })

        null_hyp = challenge_results.get('null_hypothesis')
        if null_hyp and hasattr(null_hyp, 'passed') and not null_hyp.passed:
            self.data['adversarial_feedback']['null_hypothesis_fails'].append({
                'reason': null_hyp.reasoning,
                'timestamp': datetime.now().isoformat()
            })

        false_analogy = challenge_results.get('false_analogy')
        if false_analogy and hasattr(false_analogy, 'passed') and not false_analogy.passed:
            self.data['adversarial_feedback']['false_analogies'].append({
                'counter_example': false_analogy.details.get('counter_example', ''),
                'timestamp': datetime.now().isoformat()
            })
            
    def _track_confidence(self, insight: Dict, validation: Dict):
        """Track confidence calibration"""
        original = insight['confidence']
        final = validation['final_confidence']
        status = validation['status']
        
        # Overconfident: high initial conf but rejected
        if original > 0.7 and status == 'rejected':
            self.data['confidence_calibration']['overconfident'].append({
                'original': original,
                'final': final,
                'timestamp': datetime.now().isoformat()
            })
            
        # Underconfident: low initial conf but validated
        if original < 0.5 and status == 'validated':
            self.data['confidence_calibration']['underconfident'].append({
                'original': original,
                'final': final,
                'timestamp': datetime.now().isoformat()
            })
            
    def _update_domain_stats(self, claims: List[Dict], status: str):
        """Update per-domain success rates"""
        for claim in claims:
            domain = claim.get('domain')
            if domain in self.data['domain_performance']:
                self.data['domain_performance'][domain]['total'] += 1
                if status == 'validated':
                    self.data['domain_performance'][domain]['success_rate'] = \
                        (self.data['domain_performance'][domain]['success_rate'] * 
                         (self.data['domain_performance'][domain]['total'] - 1) + 1) / \
                        self.data['domain_performance'][domain]['total']
                        
    def get_prompt_improvements(self, domain: str) -> str:
        """
        Generate improved prompts based on learning
        
        Returns additional guidance to add to agent prompts
        """
        improvements = []
        
        # Boundary violation learning
        violations = self.data['adversarial_feedback']['boundary_violations']
        recent_violations = [v for v in violations[-10:] if v['domain'] == domain]
        
        if recent_violations:
            forbidden_terms = set()
            for v in recent_violations:
                forbidden_terms.update(v['term'])
                
            improvements.append(f"""
LEARNED BOUNDARY VIOLATIONS (avoid these terms):
{', '.join(forbidden_terms)}
""")
        
        # Successful patterns
        successful = self.data['claim_patterns']['successful']
        if len(successful) > 5:
            avg_conf = sum(s['confidence'] for s in successful[-10:]) / min(len(successful), 10)
            avg_length = sum(s['length'] for s in successful[-10:]) / min(len(successful), 10)
            
            improvements.append(f"""
SUCCESSFUL PATTERNS (emulate these):
- Target confidence: {avg_conf:.2f}
- Target length: ~{int(avg_length)} characters
- Include specific mechanisms/processes
""")
        
        # Failed patterns to avoid
        failed = self.data['claim_patterns']['failed']
        if len(failed) > 3:
            improvements.append("""
AVOID THESE PATTERNS:
- Vague or overly general statements
- Circular reasoning (CS concepts derived from bio)
- Claims without mechanistic detail
""")
        
        return '\n'.join(improvements)
        
    def get_confidence_adjustment(self, base_confidence: float, domain: str) -> float:
        """
        Adjust confidence based on calibration learning
        
        Returns: Adjusted confidence score
        """
        # Check if this domain tends to be overconfident
        overconfident_count = len([c for c in self.data['confidence_calibration']['overconfident'] 
                                   if c['original'] > base_confidence])
        underconfident_count = len([c for c in self.data['confidence_calibration']['underconfident']
                                     if c['original'] < base_confidence])
        
        # Adjust based on historical calibration
        if overconfident_count > underconfident_count:
            # This confidence range tends to be too high
            return base_confidence * 0.9
        elif underconfident_count > overconfident_count:
            # This confidence range tends to be too low
            return base_confidence * 1.1
        else:
            return base_confidence
            
    def get_learning_summary(self) -> str:
        """Human-readable learning summary"""
        total_validated = len(self.data['insight_patterns']['validated'])
        total_rejected = len(self.data['insight_patterns']['rejected'])
        total = total_validated + total_rejected
        
        validation_rate = total_validated / total if total > 0 else 0
        
        cs_stats = self.data['domain_performance']['computer_science']
        bio_stats = self.data['domain_performance']['biology']
        
        return f"""
📈 LEARNING SUMMARY

Performance:
  Validation Rate: {validation_rate*100:.1f}%
  Total Insights: {total}
  
Domain Performance:
  Computer Science: {cs_stats['success_rate']*100:.1f}% ({cs_stats['total']} claims)
  Biology: {bio_stats['success_rate']*100:.1f}% ({bio_stats['total']} claims)
  
Adversarial Feedback:
  Boundary Violations: {len(self.data['adversarial_feedback']['boundary_violations'])}
  Null Hypothesis Failures: {len(self.data['adversarial_feedback']['null_hypothesis_fails'])}
  False Analogies: {len(self.data['adversarial_feedback']['false_analogies'])}
  
Confidence Calibration:
  Overconfident: {len(self.data['confidence_calibration']['overconfident'])}
  Underconfident: {len(self.data['confidence_calibration']['underconfident'])}
"""


class ImprovedAgent:
    """
    Wrapper around DomainAgent that applies learning
    """
    
    def __init__(self, domain: str, substrate, model_client, learning_system: LearningSystem):
        from agent import DomainAgent
        
        self.domain = domain
        self.substrate = substrate
        self.model_client = model_client
        self.learning = learning_system
        
        # Create base agent
        self.agent = DomainAgent(domain, substrate, model_client)
        
    def reason(self, query: str):
        """
        Reason with learned improvements
        """
        # Get learned prompt improvements
        improvements = self.learning.get_prompt_improvements(self.domain)
        
        # Modify query with learning
        enhanced_query = f"{query}\n\n{improvements}" if improvements else query
        
        # Run base agent
        self.agent.reason(enhanced_query)
        
        # Adjust confidences based on calibration learning
        self._adjust_confidences()
        
    def _adjust_confidences(self):
        """Apply learned confidence calibration to recent claims"""
        recent_claims = self.substrate.get_claims_by_domain(self.domain)[-10:]
        
        for claim in recent_claims:
            original_conf = claim.get('confidence', 0.5)
            adjusted_conf = self.learning.get_confidence_adjustment(original_conf, self.domain)
            
            # Note: In production, you'd update the claim in substrate
            # For now, just track the adjustment
            pass


if __name__ == '__main__':
    # Test the learning system
    learning = LearningSystem()
    print(learning.get_learning_summary())


path: skill.json
content: {
  "name": "epistemic_council",
  "version": "2.0.0",
  "description": "Cross-domain reasoning with adversarial validation. Orchestrated by OpenClaw.",
  "author": "Epistemic Council Project",
  "type": "skill",

  "triggers": [
    "analyze cross-domain",
    "find analogies",
    "structural similarities",
    "epistemic reasoning",
    "validated insights",
    "cross-domain patterns",
    "run council",
    "run pipeline",
    "substrate status",
    "epistemic status",
    "council status"
  ],

  "permissions": {
    "workspace": "read_write",
    "filesystem": [
      "epistemic_council/epistemic.db",
      "epistemic_council/memory/**",
      "epistemic_council/agent_learning.json",
      "epistemic_council/openclaw_memory.json"
    ],
    "shell": true,
    "network": "localhost_only"
  },

  "workspace": {
    "root": "../",
    "access": "read_write",
    "allowedPaths": [
      "epistemic_council/**",
      "memory/**",
      "HEARTBEAT.md",
      "MEMORY.md",
      "USER.md",
      "IDENTITY.md",
      "SOUL.md",
      "AGENTS.md",
      "TOOLS.md"
    ],
    "deniedPaths": []
  },

  "capabilities": {
    "reasoning": true,
    "validation": true,
    "learning": true,
    "provenance": true,
    "shell_execution": true,
    "file_read": true,
    "file_write": true
  },

  "entrypoints": {
    "dispatch": "epistemic_skill.py",
    "pipeline": "run.py",
    "health": "substrate_health.py",
    "validate": "validate_claim.py",
    "gaps": "evidence_search.py",
    "rechallenge": "adversarial_rechallenge.py",
    "boundary": "boundary_audit.py",
    "compress": "memory_compress.py"
  },

  "requirements": {
    "python": ">=3.8",
    "ollama": true,
    "model": "qwen2.5:14b"
  },

  "configuration": {
    "min_confidence": 0.5,
    "max_insights": 5,
    "enable_learning": true,
    "validation_mode": "strict",
    "db_path": "epistemic.db",
    "memory_dir": "memory"
  },

  "storage": {
    "database": "epistemic.db",
    "learning": "agent_learning.json",
    "memory": "openclaw_memory.json",
    "heartbeat_state": "memory/heartbeat-state.json",
    "runs_log": "memory/openclaw-runs/"
  }
}


path: skill_downloader.py
content: # skill_downloader.py — DEPRECATED
#
# This file was created in error. Skill installation is handled natively
# by OpenClaw's built-in skill system. No custom downloader is needed.
#
# To install skills: use OpenClaw's native skill commands.
# See AGENTS.md and TOOLS.md for guidance.
#
# This file is safe to delete.


path: substrate.py
content: """
substrate.py — Epistemic Council v2.0
Append-only SQLite substrate with provenance DAG.

FIXES (Gap Analysis §3.1):
  - Removed duplicate _row_to_event definition
  - Fixed tab indentation in get_all_events()
  - Added 6 new read methods for monitoring integration (§4.1)
"""

import sqlite3
import hashlib
import json
import uuid
from datetime import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
from collections import deque


class EventType(Enum):
    CLAIM_CREATED = "claim_created"
    EVIDENCE_LINKED = "evidence_linked"
    HYPOTHESIS_FORKED = "hypothesis_forked"
    INSIGHT_GENERATED = "insight_generated"
    ADVERSARIAL_CHALLENGE = "adversarial_challenge"
    CHALLENGE_RESULT = "challenge_result"
    HUMAN_REVIEW = "human_review"
    VISIBILITY_PRUNED = "visibility_pruned"


class InsightType(Enum):
    ANALOGICAL = "A"
    CONSTRAINT_TRANSFER = "C"
    OPTIMIZATION_PATTERN = "O"
    STATISTICAL_CORRELATION = "S"


@dataclass
class SubstrateEvent:
    event_id: str
    event_type: EventType
    agent_id: str
    domain: str
    content: Dict[str, Any]
    parent_ids: List[str]
    integrity_hash: str
    timestamp: str
    confidence: float = 0.0
    visibility_score: float = 1.0


class Substrate:
    """
    Append-only epistemic substrate. No UPDATE or DELETE paths exist.
    All writes go through validated write_*() methods.
    """

    SCHEMA = """
    CREATE TABLE IF NOT EXISTS events (
        event_id        TEXT PRIMARY KEY,
        event_type      TEXT NOT NULL,
        agent_id        TEXT NOT NULL,
        domain          TEXT NOT NULL,
        content         TEXT NOT NULL,
        parent_ids      TEXT NOT NULL DEFAULT '[]',
        integrity_hash  TEXT NOT NULL,
        timestamp       TEXT NOT NULL,
        confidence      REAL DEFAULT 0.0,
        visibility_score REAL DEFAULT 1.0
    );
    CREATE INDEX IF NOT EXISTS idx_domain    ON events(domain);
    CREATE INDEX IF NOT EXISTS idx_agent     ON events(agent_id);
    CREATE INDEX IF NOT EXISTS idx_type      ON events(event_type);
    CREATE INDEX IF NOT EXISTS idx_timestamp ON events(timestamp);
    """

    def __init__(self, db_path: str = "epistemic.db"):
        self.db_path = db_path
        self._conn = sqlite3.connect(db_path, check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._init_schema()

    def _init_schema(self):
        self._conn.executescript(self.SCHEMA)
        self._conn.commit()

    # ------------------------------------------------------------------
    # SINGLE _row_to_event definition (Gap §3.1 fix — duplicate removed)
    # ------------------------------------------------------------------
    def _row_to_event(self, row) -> SubstrateEvent:
        event_type_val = row["event_type"]
        if isinstance(event_type_val, str):
            try:
                event_type = EventType(event_type_val)
            except ValueError:
                # Fallback: match by name
                event_type = EventType[event_type_val.upper()]
        else:
            event_type = event_type_val

        return SubstrateEvent(
            event_id=row["event_id"],
            event_type=event_type,
            agent_id=row["agent_id"],
            domain=row["domain"],
            content=json.loads(row["content"]),
            parent_ids=json.loads(row["parent_ids"]),
            integrity_hash=row["integrity_hash"],
            timestamp=row["timestamp"],
            confidence=row["confidence"],
            visibility_score=row["visibility_score"],
        )

    def _compute_hash(self, event_type: str, agent_id: str, domain: str,
                      content: dict, parent_ids: list) -> str:
        payload = json.dumps({
            "event_type": event_type,
            "agent_id": agent_id,
            "domain": domain,
            "content": content,
            "parent_ids": sorted(parent_ids),
        }, sort_keys=True)
        return hashlib.sha256(payload.encode()).hexdigest()

    def _append(self, event_type: EventType, agent_id: str, domain: str,
                 content: dict, parent_ids: list, confidence: float = 0.0) -> SubstrateEvent:
        event_id = str(uuid.uuid4())
        timestamp = datetime.utcnow().isoformat()
        integrity_hash = self._compute_hash(
            event_type.value, agent_id, domain, content, parent_ids
        )
        self._conn.execute(
            """INSERT INTO events
               (event_id, event_type, agent_id, domain, content,
                parent_ids, integrity_hash, timestamp, confidence)
               VALUES (?,?,?,?,?,?,?,?,?)""",
            (event_id, event_type.value, agent_id, domain,
             json.dumps(content), json.dumps(parent_ids),
             integrity_hash, timestamp, confidence)
        )
        self._conn.commit()
        return SubstrateEvent(
            event_id=event_id, event_type=event_type,
            agent_id=agent_id, domain=domain, content=content,
            parent_ids=parent_ids, integrity_hash=integrity_hash,
            timestamp=timestamp, confidence=confidence
        )

    # ------------------------------------------------------------------
    # Write methods
    # ------------------------------------------------------------------
    def write_claim(self, agent_id: str, domain: str, claim_text: str,
                    confidence: float, reasoning_trace: str = "",
                    parent_ids: list = None) -> SubstrateEvent:
        content = {
            "claim": claim_text,
            "reasoning_trace": reasoning_trace,
            "risk_score": round((1.0 - confidence) * 1.2, 4),  # computed proxy
        }
        return self._append(
            EventType.CLAIM_CREATED, agent_id, domain,
            content, parent_ids or [], confidence
        )

    def write_insight(self, agent_id: str, domain: str, insight_text: str,
                      insight_type: InsightType, confidence: float,
                      source_claim_ids: list, cross_domain_flag: bool = False) -> SubstrateEvent:
        if not source_claim_ids:
            raise ValueError("write_insight requires at least one source_claim_id")
        content = {
            "insight": insight_text,
            "insight_type": insight_type.value,
            "cross_domain_flag": cross_domain_flag,
            "adversarial_status": None,  # populated by challenge_result
        }
        return self._append(
            EventType.INSIGHT_GENERATED, agent_id, domain,
            content, source_claim_ids, confidence
        )

    def write_challenge_result(self, insight_id: str, agent_id: str,
                                verdict: dict, confidence_adjustment: float) -> SubstrateEvent:
        content = {
            "verdict": verdict,
            "confidence_adjustment": confidence_adjustment,
        }
        return self._append(
            EventType.CHALLENGE_RESULT, agent_id, "adversarial",
            content, [insight_id], 0.0
        )

    def write_human_review(self, claim_id: str, reviewer: str,
                            decision: str, notes: str) -> SubstrateEvent:
        content = {
            "decision": decision,
            "notes": notes,
            "reviewer": reviewer,
        }
        return self._append(
            EventType.HUMAN_REVIEW, reviewer, "governance",
            content, [claim_id], 0.0
        )

    def write_evidence_link(self, claim_id: str, agent_id: str,
                             source_url: str, reliability: str) -> SubstrateEvent:
        content = {
            "source_url": source_url,
            "reliability": reliability,
        }
        return self._append(
            EventType.EVIDENCE_LINKED, agent_id, "evidence",
            content, [claim_id], 0.0
        )

    # ------------------------------------------------------------------
    # Existing read methods
    # ------------------------------------------------------------------
    def get_event(self, event_id: str) -> Optional[SubstrateEvent]:
        row = self._conn.execute(
            "SELECT * FROM events WHERE event_id = ?", (event_id,)
        ).fetchone()
        return self._row_to_event(row) if row else None

    def get_claims_by_domain(self, domain: str) -> List[SubstrateEvent]:
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? AND domain = ? ORDER BY timestamp",
            (EventType.CLAIM_CREATED.value, domain)
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_insights(self) -> List[SubstrateEvent]:
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? ORDER BY timestamp",
            (EventType.INSIGHT_GENERATED.value,)
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_challenges_for_insight(self, insight_id: str) -> List[SubstrateEvent]:
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? AND parent_ids LIKE ?",
            (EventType.CHALLENGE_RESULT.value, f'%{insight_id}%')
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_provenance(self, event_id: str) -> List[SubstrateEvent]:
        """BFS walk of provenance DAG."""
        visited, queue, results = set(), deque([event_id]), []
        while queue:
            eid = queue.popleft()
            if eid in visited:
                continue
            visited.add(eid)
            ev = self.get_event(eid)
            if ev:
                results.append(ev)
                for pid in ev.parent_ids:
                    queue.append(pid)
        return results

    def event_count(self) -> int:
        return self._conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]

    def verify_all(self) -> Dict[str, Any]:
        rows = self._conn.execute("SELECT * FROM events").fetchall()
        passed, failed = 0, []
        for row in rows:
            ev = self._row_to_event(row)
            expected = self._compute_hash(
                ev.event_type.value, ev.agent_id, ev.domain,
                ev.content, ev.parent_ids
            )
            if ev.integrity_hash == expected:
                passed += 1
            else:
                failed.append(ev.event_id)
        return {"passed": passed, "failed": failed, "total": passed + len(failed)}

    def prune_visibility(self, event_id: str, reason: str):
        """Affects visibility_score only — never deletes."""
        self._conn.execute(
            "UPDATE events SET visibility_score = 0.0 WHERE event_id = ?",
            (event_id,)
        )
        self._append(
            EventType.VISIBILITY_PRUNED, "system", "governance",
            {"target_id": event_id, "reason": reason}, [event_id]
        )

    # ------------------------------------------------------------------
    # NEW READ METHODS — monitoring integration (Gap Analysis §4.1)
    # ------------------------------------------------------------------

    def get_all_events(self, limit: int = None) -> List[SubstrateEvent]:
        """Return all events ordered by timestamp. (Gap §3.1 — tab fix applied)"""
        sql = "SELECT * FROM events ORDER BY timestamp"
        if limit:
            sql += f" LIMIT {int(limit)}"
        rows = self._conn.execute(sql).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_events_by_type(self, event_type: EventType,
                            domain: str = None,
                            limit: int = None) -> List[SubstrateEvent]:
        """
        Flexible event filter used by Tasks 1, 2, 4.
        Monitoring integration §4.1, method 1.
        """
        params = [event_type.value]
        sql = "SELECT * FROM events WHERE event_type = ?"
        if domain:
            sql += " AND domain = ?"
            params.append(domain)
        sql += " ORDER BY timestamp DESC"
        if limit:
            sql += f" LIMIT {int(limit)}"
        rows = self._conn.execute(sql, params).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_challenge_results_for_insight(self, insight_id: str) -> List[SubstrateEvent]:
        """
        All CHALLENGE_RESULT events referencing an insight.
        Enables full audit trail for Tasks 3 and 4.
        Monitoring integration §4.1, method 2.
        """
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? AND parent_ids LIKE ? ORDER BY timestamp",
            (EventType.CHALLENGE_RESULT.value, f'%{insight_id}%')
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_claims_without_evidence(self, domain: str = None) -> List[SubstrateEvent]:
        """
        Claims with no EVIDENCE_LINKED child events.
        Core to orphan/weak-evidence detection for Task 2.
        Monitoring integration §4.1, method 3.
        """
        # Get all claim IDs
        params = [EventType.CLAIM_CREATED.value]
        sql = "SELECT * FROM events WHERE event_type = ?"
        if domain:
            sql += " AND domain = ?"
            params.append(domain)
        claim_rows = self._conn.execute(sql, params).fetchall()

        # Get all claim IDs referenced by EVIDENCE_LINKED events
        evidence_rows = self._conn.execute(
            "SELECT parent_ids FROM events WHERE event_type = ?",
            (EventType.EVIDENCE_LINKED.value,)
        ).fetchall()
        evidenced_ids = set()
        for row in evidence_rows:
            for pid in json.loads(row["parent_ids"]):
                evidenced_ids.add(pid)

        results = []
        for row in claim_rows:
            ev = self._row_to_event(row)
            if ev.event_id not in evidenced_ids:
                results.append(ev)
        return results

    def get_claims_in_confidence_range(self, low: float, high: float,
                                        domain: str = None) -> List[SubstrateEvent]:
        """
        Claims within a confidence band. Used by Task 3 for threshold-zone
        validation (0.45–0.55 range = 'challenged' epistemic zone).
        Monitoring integration §4.1, method 4.
        """
        params = [EventType.CLAIM_CREATED.value, low, high]
        sql = "SELECT * FROM events WHERE event_type = ? AND confidence BETWEEN ? AND ?"
        if domain:
            sql += " AND domain = ?"
            params.append(domain)
        sql += " ORDER BY confidence"
        rows = self._conn.execute(sql, params).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_hypothesis_forks(self, parent_claim_id: str) -> List[SubstrateEvent]:
        """
        All HYPOTHESIS_FORKED events from a parent claim.
        Enables divergence cluster detection for Task 5.
        Monitoring integration §4.1, method 5.
        """
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? AND parent_ids LIKE ?",
            (EventType.HYPOTHESIS_FORKED.value, f'%{parent_claim_id}%')
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def count_children(self, event_id: str) -> int:
        """
        Count events where event_id appears in parent_ids.
        Used for orphan detection (no parents AND no children = orphan).
        Monitoring integration §4.1, method 6.
        """
        rows = self._conn.execute(
            "SELECT COUNT(*) FROM events WHERE parent_ids LIKE ?",
            (f'%{event_id}%',)
        ).fetchone()
        return rows[0]

    def get_insights_above_confidence(self, threshold: float) -> List[SubstrateEvent]:
        """
        Insights above a confidence threshold. Used by Task 4 to select
        re-challenge candidates (PDR: high-confidence = highest epistemic risk).
        """
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? AND confidence > ? ORDER BY confidence DESC",
            (EventType.INSIGHT_GENERATED.value, threshold)
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_human_review_decisions(self, claim_id: str) -> List[SubstrateEvent]:
        """All human review events for a given claim."""
        rows = self._conn.execute(
            "SELECT * FROM events WHERE event_type = ? AND parent_ids LIKE ?",
            (EventType.HUMAN_REVIEW.value, f'%{claim_id}%')
        ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def is_visibility_pruned(self, event_id: str) -> bool:
        """Check if an event has been pruned from default visibility."""
        row = self._conn.execute(
            "SELECT 1 FROM events WHERE visibility_score = 0.0 AND event_id = ?",
            (event_id,)
        ).fetchone()
        return row is not None

    def close(self):
        self._conn.close()

path: substrate_health.py
content: #!/usr/bin/env python3
"""
openclaw_tasks/substrate_health.py
Task 1 — Substrate Health Monitoring

Runs every heartbeat cycle (Cycle 1).
Scans for:
  - Unverified high-risk claims (risk proxy: (1 - confidence) × 1.2 for cross-domain)
  - Claims with no evidence linked (weak evidence)
  - Orphan claims (no parents, no children)
  - Divergence clusters (>= N competing forks from same parent)

Writes results to memory/openclaw-runs/YYYY-MM-DD.json
Prints JSON to stdout for heartbeat capture.

Safety: read-only. No substrate writes.
"""

import sys
import json
import os
from datetime import datetime
from pathlib import Path

# Resolve workspace root (this file lives in openclaw_tasks/)
WORKSPACE = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(WORKSPACE))

from substrate import Substrate, EventType

# Thresholds (aligned with RFC-0006 and monitoring proposal)
RISK_THRESHOLD = 0.7           # computed: (1 - confidence) × 1.2 ≥ 0.7
HIGH_CONFIDENCE_THRESHOLD = 0.75
DIVERGENCE_CLUSTER_MIN = 3     # ≥ 3 forks from same parent = cluster
MIN_EVENTS_FOR_HEALTH_CHECK = 10


def compute_risk_proxy(confidence: float, cross_domain: bool = False) -> float:
    """
    Proxy for risk_score (field not yet in schema).
    cross-domain claims get a 1.3× multiplier to reflect higher epistemic risk.
    """
    base = (1.0 - confidence) * 1.2
    if cross_domain:
        base *= 1.3
    return round(min(base, 1.0), 4)


def check_substrate_health(db_path: str) -> dict:
    substrate = Substrate(db_path)
    total = substrate.event_count()

    if total < MIN_EVENTS_FOR_HEALTH_CHECK:
        return {
            "alert": "SKIP",
            "reason": f"Insufficient data: {total} events (min {MIN_EVENTS_FOR_HEALTH_CHECK})",
            "total_events": total,
            "timestamp": datetime.utcnow().isoformat(),
        }

    # ── 1. High-risk unverified claims ──────────────────────────────
    all_claims = substrate.get_events_by_type(EventType.CLAIM_CREATED)
    high_risk_unverified = []
    for claim in all_claims:
        cross = claim.content.get("cross_domain_flag", False)
        risk = compute_risk_proxy(claim.confidence, cross)
        if risk >= RISK_THRESHOLD:
            reviews = substrate.get_human_review_decisions(claim.event_id)
            approved = any(
                r.content.get("decision") == "approved" for r in reviews
            )
            if not approved:
                high_risk_unverified.append({
                    "event_id": claim.event_id,
                    "domain": claim.domain,
                    "confidence": claim.confidence,
                    "risk_proxy": risk,
                    "timestamp": claim.timestamp,
                })

    # ── 2. Weak evidence claims ──────────────────────────────────────
    weak_evidence = []
    for claim in all_claims:
        children_count = substrate.count_children(claim.event_id)
        # count_children includes all child events; we want evidence-specific
        # Use get_claims_without_evidence for domain-agnostic check
        if children_count == 0:
            weak_evidence.append({
                "event_id": claim.event_id,
                "domain": claim.domain,
                "confidence": claim.confidence,
            })

    # ── 3. Orphan claims (no parents AND no children) ────────────────
    orphans = []
    no_evidence_claims = substrate.get_claims_without_evidence()
    for claim in no_evidence_claims:
        if not claim.parent_ids and substrate.count_children(claim.event_id) == 0:
            orphans.append({
                "event_id": claim.event_id,
                "domain": claim.domain,
                "confidence": claim.confidence,
            })

    # ── 4. Divergence clusters ────────────────────────────────────────
    divergence_clusters = []
    fork_events = substrate.get_events_by_type(EventType.HYPOTHESIS_FORKED)
    # Group by parent
    forks_by_parent: dict = {}
    for fe in fork_events:
        for pid in fe.parent_ids:
            forks_by_parent.setdefault(pid, []).append(fe.event_id)
    for parent_id, fork_ids in forks_by_parent.items():
        if len(fork_ids) >= DIVERGENCE_CLUSTER_MIN:
            divergence_clusters.append({
                "parent_id": parent_id,
                "fork_count": len(fork_ids),
            })

    # ── 5. High-confidence insights flagged for re-challenge ─────────
    high_conf_insights = substrate.get_insights_above_confidence(HIGH_CONFIDENCE_THRESHOLD)

    # ── Alert level ───────────────────────────────────────────────────
    critical = len(high_risk_unverified) > 10 or len(divergence_clusters) > 5
    alert = "CRITICAL" if critical else "OK"

    report = {
        "alert": alert,
        "timestamp": datetime.utcnow().isoformat(),
        "total_events": total,
        "high_risk_unverified": {
            "count": len(high_risk_unverified),
            "items": high_risk_unverified[:5],  # top 5 for brevity
        },
        "weak_evidence": {
            "count": len(weak_evidence),
        },
        "orphan_claims": {
            "count": len(orphans),
            "items": orphans[:5],
        },
        "divergence_clusters": {
            "count": len(divergence_clusters),
            "items": divergence_clusters,
        },
        "high_confidence_insights_pending_rechallenge": {
            "count": len(high_conf_insights),
            "ids": [i.event_id for i in high_conf_insights[:3]],
        },
    }

    substrate.close()
    return report


def write_run_log(report: dict, workspace: Path):
    """Append report to daily openclaw-runs log."""
    runs_dir = workspace / "memory" / "openclaw-runs"
    runs_dir.mkdir(parents=True, exist_ok=True)
    log_file = runs_dir / f"{datetime.utcnow().strftime('%Y-%m-%d')}.json"

    existing = []
    if log_file.exists():
        try:
            existing = json.loads(log_file.read_text())
        except Exception:
            existing = []

    existing.append({"task": "substrate_health", **report})
    log_file.write_text(json.dumps(existing, indent=2))

    # Also update latest snapshot
    snapshot = workspace / "memory" / "substrate-health.json"
    snapshot.write_text(json.dumps(report, indent=2))


def main():
    db_path = str(WORKSPACE / "epistemic.db")
    if len(sys.argv) > 1:
        db_path = sys.argv[1]

    report = check_substrate_health(db_path)
    write_run_log(report, WORKSPACE)
    print(json.dumps(report, indent=2))

    # Exit 2 on CRITICAL so heartbeat can detect it
    if report.get("alert") == "CRITICAL":
        sys.exit(2)


if __name__ == "__main__":
    main()

path: validate_claim.py
content: #!/usr/bin/env python3
"""
openclaw_tasks/validate_claim.py
Task 3 — Claim Validation & Strengthening

Targets claims in the 'challenged zone' (confidence 0.45–0.55).
These are claims that passed initial generation but sit below the epistemic
confidence needed for reinforcement. Re-validating them is highest ROI.

For each candidate claim:
  1. Retrieves its full content and reasoning_trace from substrate
  2. Calls local Ollama to re-evaluate whether the claim still holds
  3. Checks if prior adversarial challenges exist (prior challenge history)
  4. Suggests confidence adjustment (up/down/unchanged)
  5. Logs result to memory/openclaw-runs/ for human review

Does NOT modify the substrate. Suggestions are logged only.
Human or next pipeline run must act on suggestions.

Usage:
  python validate_claim.py [db_path] [--domain DOMAIN] [--low 0.45] [--high 0.55]
"""

import sys
import json
import re
import os
import http.client
from datetime import datetime
from pathlib import Path

WORKSPACE = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(WORKSPACE))

from substrate import Substrate, EventType

# Defaults
DEFAULT_LOW = 0.45
DEFAULT_HIGH = 0.55
OLLAMA_HOST = "localhost"
OLLAMA_PORT = 11434
OLLAMA_MODEL = "glm-4.7-flash"  # from monitoring spec
MAX_CLAIMS_PER_RUN = 5          # prevent token burn (monitoring proposal §8)


VALIDATION_PROMPT = """You are an epistemic validation agent. A claim was previously generated with moderate confidence and must be re-evaluated.

CLAIM: {claim}

DOMAIN: {domain}

REASONING TRACE (original): {reasoning_trace}

PRIOR ADVERSARIAL CHALLENGES: {challenges}

Your task:
1. Evaluate whether this claim is still epistemically justified given the reasoning trace.
2. Identify any weaknesses or gaps in the reasoning.
3. Recommend a confidence adjustment: INCREASE, DECREASE, or UNCHANGED.
4. If DECREASE or INCREASE, estimate the magnitude (e.g. -0.10, +0.08).

Respond in this exact format:
VERDICT: [PASS|FAIL|UNCERTAIN]
ADJUSTMENT: [INCREASE|DECREASE|UNCHANGED]
MAGNITUDE: [float between -0.5 and +0.5, or 0.0 if UNCHANGED]
REASONING: [one to three sentences explaining your evaluation]
"""


def call_ollama(prompt: str) -> str:
    """Call local Ollama HTTP API. Returns raw text response."""
    try:
        payload = json.dumps({
            "model": OLLAMA_MODEL,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": 0.2},
        }).encode()
        conn = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=30)
        conn.request("POST", "/api/generate", payload, {"Content-Type": "application/json"})
        resp = conn.getresponse()
        if resp.status != 200:
            return f"Ollama error: HTTP {resp.status}"
        data = json.loads(resp.read())
        return data.get("response", "").strip()
    except Exception as e:
        return f"Ollama unavailable: {e}"


def parse_validation_response(text: str) -> dict:
    """Extract structured fields from Ollama validation response."""
    result = {
        "verdict": "UNCERTAIN",
        "adjustment": "UNCHANGED",
        "magnitude": 0.0,
        "reasoning": "",
    }
    lines = text.strip().splitlines()
    for line in lines:
        line = line.strip()
        if line.startswith("VERDICT:"):
            result["verdict"] = line.split(":", 1)[1].strip().upper()
        elif line.startswith("ADJUSTMENT:"):
            result["adjustment"] = line.split(":", 1)[1].strip().upper()
        elif line.startswith("MAGNITUDE:"):
            try:
                result["magnitude"] = float(line.split(":", 1)[1].strip())
            except ValueError:
                result["magnitude"] = 0.0
        elif line.startswith("REASONING:"):
            result["reasoning"] = line.split(":", 1)[1].strip()
    return result


def validate_claim(claim_event, substrate: Substrate) -> dict:
    """Run validation on a single claim event. Returns result dict."""
    content = claim_event.content
    claim_text = content.get("claim", "")
    reasoning_trace = content.get("reasoning_trace", "No reasoning trace stored.")

    # Get prior challenge history for this claim
    challenges = substrate.get_challenge_results_for_insight(claim_event.event_id)
    challenge_summary = "None" if not challenges else (
        f"{len(challenges)} prior challenge(s). "
        "Last verdict: " + str(challenges[-1].content.get("verdict", {}).get("category", "unknown"))
    )

    prompt = VALIDATION_PROMPT.format(
        claim=claim_text[:500],
        domain=claim_event.domain,
        reasoning_trace=reasoning_trace[:400],
        challenges=challenge_summary,
    )

    raw_response = call_ollama(prompt)
    parsed = parse_validation_response(raw_response)

    return {
        "claim_id": claim_event.event_id,
        "domain": claim_event.domain,
        "current_confidence": claim_event.confidence,
        "claim_snippet": claim_text[:150],
        "verdict": parsed["verdict"],
        "suggested_adjustment": parsed["adjustment"],
        "suggested_magnitude": parsed["magnitude"],
        "suggested_new_confidence": round(
            claim_event.confidence + parsed["magnitude"], 4
        ),
        "reasoning": parsed["reasoning"],
        "prior_challenges": len(challenges),
        "validated_at": datetime.utcnow().isoformat(),
        "status": "needs_human_review" if parsed["verdict"] == "FAIL" else "suggestion",
    }


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("db_path", nargs="?", default=str(WORKSPACE / "epistemic.db"))
    parser.add_argument("--domain", default=None)
    parser.add_argument("--low", type=float, default=DEFAULT_LOW)
    parser.add_argument("--high", type=float, default=DEFAULT_HIGH)
    parser.add_argument("--claim-id", default=None,
                        help="Validate a specific claim by ID instead of range scan")
    args = parser.parse_args()

    substrate = Substrate(args.db_path)
    date_str = datetime.utcnow().strftime("%Y-%m-%d")

    if args.claim_id:
        claim_event = substrate.get_event(args.claim_id)
        if not claim_event:
            print(json.dumps({"error": f"Claim {args.claim_id} not found"}))
            sys.exit(1)
        candidates = [claim_event]
    else:
        candidates = substrate.get_claims_in_confidence_range(
            args.low, args.high, domain=args.domain
        )

    candidates = candidates[:MAX_CLAIMS_PER_RUN]

    if not candidates:
        result = {
            "status": "no_candidates",
            "range": [args.low, args.high],
            "domain": args.domain,
            "timestamp": datetime.utcnow().isoformat(),
        }
        print(json.dumps(result, indent=2))
        substrate.close()
        return

    results = []
    for claim in candidates:
        print(f"[validate_claim] Evaluating {claim.event_id[:8]}... (conf={claim.confidence:.2f})",
              file=sys.stderr)
        result = validate_claim(claim, substrate)
        results.append(result)

    # Write to openclaw-runs log
    runs_dir = WORKSPACE / "memory" / "openclaw-runs"
    runs_dir.mkdir(parents=True, exist_ok=True)
    log_file = runs_dir / f"{date_str}.json"
    existing = []
    if log_file.exists():
        try:
            existing = json.loads(log_file.read_text())
        except Exception:
            pass
    existing.append({
        "task": "validate_claim",
        "timestamp": datetime.utcnow().isoformat(),
        "results": results,
    })
    log_file.write_text(json.dumps(existing, indent=2))

    substrate.close()

    output = {
        "status": "ok",
        "validated_count": len(results),
        "range": [args.low, args.high],
        "results": results,
    }
    print(json.dumps(output, indent=2))


if __name__ == "__main__":
    main()

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)