crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! `cortex context ...` — Phase 2 context-pack command surface.
//!
//! Context packs must be auditable and redacted by default.

use clap::{Args, Subcommand};
use cortex_context::{
    axiom_export_for_pack, ContextPackBuilder, ContextRefCandidate, ContextRefId, Sensitivity,
};
use cortex_core::{AuthorityClass, ClaimCeiling, RuntimeMode};
use cortex_retrieval::{
    query_fts5, resolve_conflicts, AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput,
    ProofClosureHint,
};
use cortex_store::proof::verify_memory_proof_closure;
use cortex_store::repo::{ContradictionRepo, MemoryRecord, MemoryRepo, PrincipleRepo};
use cortex_store::Pool;
use std::collections::{BTreeMap, BTreeSet};

use crate::cmd::open_default_store;
use crate::exit::Exit;
use crate::output::{self, Envelope};

/// `cortex context ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum ContextSub {
    /// Build an auditable context pack.
    Build(BuildArgs),
}

/// `cortex context build` arguments.
#[derive(Debug, Args)]
pub struct BuildArgs {
    /// Task the context pack should support.
    #[arg(long)]
    pub task: String,

    /// Maximum output token budget for the pack.
    #[arg(long = "max-tokens", default_value_t = 4096)]
    pub max_tokens: usize,

    /// Print AXIOM constraints instead of the full context pack.
    #[arg(long = "axiom-constraints")]
    pub axiom_constraints: bool,

    /// Restrict pack candidates to active memories whose `domains_json`
    /// carries this tag. Repeat the flag to AND multiple tags together; a
    /// memory must carry every supplied tag to be considered.
    #[arg(long = "tag", value_name = "TAG")]
    pub tag: Vec<String>,

    /// Opt-in fuzzy retrieval over the FTS5 trigram mirror (Phase 4.B).
    ///
    /// Default OFF: pack candidates come from the full set of active
    /// memories (subject to `--tag`), byte-for-byte identical to the
    /// Phase 4.A baseline. When set, the `--task` text is treated as a
    /// fuzzy retrieval query and only memories that match it under the
    /// FTS5 trigram tokenizer (typo-tolerant) are considered for pack
    /// selection. Default-path callers see no behavioural change.
    #[arg(long)]
    pub fuzzy: bool,

    /// Include promoted doctrine entries in the context pack output.
    ///
    /// When set, active doctrine records (promoted principles) are appended
    /// to the context pack as a `doctrine` section. Each entry carries the
    /// principle claim, confidence, and the audit ref of its promotion.
    /// Operators use this to ensure that session guidance from promoted
    /// principles is visible to the AI model receiving the context pack.
    #[arg(long)]
    pub include_doctrine: bool,
}

/// Run a `cortex context ...` command.
pub fn run(sub: ContextSub) -> Exit {
    match sub {
        ContextSub::Build(args) => build(args),
    }
}

fn build(args: BuildArgs) -> Exit {
    if args.task.trim().is_empty() {
        eprintln!("cortex context build: --task must not be empty");
        return build_failure_envelope(Exit::Usage, "--task must not be empty");
    }
    if args.max_tokens == 0 {
        eprintln!("cortex context build: --max-tokens must be greater than zero");
        return build_failure_envelope(Exit::Usage, "--max-tokens must be greater than zero");
    }

    let axiom_constraints = args.axiom_constraints;
    let include_doctrine = args.include_doctrine;
    match build_pack(args) {
        Ok(pack) => {
            if let Err(err) = pack.require_default_use_allowed() {
                eprintln!("cortex context build: {err}");
                return build_failure_envelope(Exit::PreconditionUnmet, &err.to_string());
            }
            let mut payload = if axiom_constraints {
                match serde_json::to_value(axiom_export_for_pack(&pack)) {
                    Ok(value) => value,
                    Err(err) => {
                        eprintln!("cortex context build: failed to serialize output: {err}");
                        return build_failure_envelope(
                            Exit::Internal,
                            &format!("failed to serialize output: {err}"),
                        );
                    }
                }
            } else {
                match serde_json::to_value(&pack) {
                    Ok(value) => value,
                    Err(err) => {
                        eprintln!("cortex context build: failed to serialize output: {err}");
                        return build_failure_envelope(
                            Exit::Internal,
                            &format!("failed to serialize output: {err}"),
                        );
                    }
                }
            };

            if include_doctrine {
                if let Err(exit) = attach_doctrine_section(&mut payload) {
                    return build_failure_envelope(exit, "failed to read doctrine");
                }
            }

            if output::json_enabled() {
                let envelope = Envelope::new("cortex.context.build", Exit::Ok, payload);
                return output::emit(&envelope, Exit::Ok);
            }
            match serde_json::to_string_pretty(&payload) {
                Ok(serialized) => {
                    println!("{serialized}");
                    Exit::Ok
                }
                Err(err) => {
                    eprintln!("cortex context build: failed to serialize output: {err}");
                    Exit::Internal
                }
            }
        }
        Err(exit) => build_failure_envelope(exit, "context-pack build failed"),
    }
}

/// Reads all doctrine rows from the store and attaches them as a `doctrine`
/// array to the serialized context pack payload.  Fails gracefully: if no
/// doctrine exists the array is empty, never an error.
fn attach_doctrine_section(payload: &mut serde_json::Value) -> Result<(), Exit> {
    let pool = open_default_store("context build --include-doctrine")?;
    let repo = PrincipleRepo::new(&pool);
    let doctrine = repo.list_doctrine().map_err(|err| {
        eprintln!("cortex context build: failed to read doctrine: {err}");
        Exit::Internal
    })?;

    let entries: Vec<serde_json::Value> = doctrine
        .iter()
        .map(|d| {
            serde_json::json!({
                "doctrine_id": d.id.to_string(),
                "source_principle": d.source_principle.to_string(),
                "claim": d.rule,
                "force": d.force,
                "promotion_reason": d.promotion_reason,
                "promoted_at": d.created_at.to_rfc3339(),
            })
        })
        .collect();

    if let Some(obj) = payload.as_object_mut() {
        obj.insert("doctrine".to_string(), serde_json::Value::Array(entries));
    }
    Ok(())
}

fn build_failure_envelope(exit: Exit, detail: &str) -> Exit {
    if !output::json_enabled() {
        return exit;
    }
    let payload = serde_json::json!({
        "status": "error",
        "detail": detail,
    });
    let envelope = Envelope::new("cortex.context.build", exit, payload);
    output::emit(&envelope, exit)
}

pub(crate) fn build_pack(args: BuildArgs) -> Result<cortex_context::ContextPack, Exit> {
    let pool = open_default_store("context build")?;
    let repo = MemoryRepo::new(&pool);
    let mut active = if args.tag.is_empty() {
        repo.list_by_status("active").map_err(|err| {
            eprintln!("cortex context build: failed to read active memories: {err}");
            Exit::Internal
        })?
    } else {
        repo.list_by_status_with_tags("active", &args.tag)
            .map_err(|err| {
                eprintln!(
                    "cortex context build: failed to read tag-filtered active memories: {err}"
                );
                Exit::Internal
            })?
    };

    // Phase 4.B fuzzy retrieval (opt-in). When --fuzzy is ON we narrow
    // the candidate set to memories the FTS5 trigram mirror surfaces
    // for `--task`, preserving the existing tag filter. When --fuzzy
    // is OFF the candidate set is untouched — the Phase 4.A baseline
    // (every active memory subject to --tag) ships byte-for-byte
    // identical.
    if args.fuzzy {
        let limit = std::cmp::max(active.len(), 16);
        let fts_hits = query_fts5(&repo, &args.task, limit).map_err(|err| {
            eprintln!("cortex context build: fuzzy retrieval failed: {err}");
            Exit::Internal
        })?;
        let matched: BTreeSet<String> = fts_hits
            .iter()
            .map(|hit| hit.memory_id.to_string())
            .collect();
        active.retain(|memory| matched.contains(&memory.id.to_string()));
    }

    let mut builder = ContextPackBuilder::new(args.task, args.max_tokens);
    for memory in &active {
        let proof = verify_memory_proof_closure(&pool, &memory.id).map_err(|err| {
            eprintln!(
                "cortex context build: failed to verify memory {} proof closure: {err}",
                memory.id
            );
            Exit::PreconditionUnmet
        })?;
        if let Err(err) = proof.require_current_use_allowed() {
            eprintln!(
                "cortex context build: memory {} excluded from default context use: {err}",
                memory.id
            );
            return Err(Exit::PreconditionUnmet);
        }
        builder = builder.select_ref(
            ContextRefCandidate::new(
                ContextRefId::Memory {
                    memory_id: memory.id,
                },
                memory.claim.clone(),
            )
            .with_claim_metadata(
                RuntimeMode::LocalUnsigned,
                AuthorityClass::Derived,
                proof.state().into(),
                ClaimCeiling::LocalUnsigned,
            )
            .with_sensitivity(Sensitivity::Internal),
        );
    }
    if let Err(err) = gate_open_contradictions_for_default_context(&pool, &active) {
        eprintln!("cortex context build: {err}");
        return Err(Exit::PreconditionUnmet);
    }

    builder.build().map_err(|err| {
        eprintln!("cortex context build: {err}");
        Exit::PreconditionUnmet
    })
}

fn gate_open_contradictions_for_default_context(
    pool: &Pool,
    memories: &[MemoryRecord],
) -> Result<(), String> {
    let active_by_id = memories
        .iter()
        .map(|memory| (memory.id.to_string(), memory))
        .collect::<BTreeMap<_, _>>();
    let contradictions = ContradictionRepo::new(pool)
        .list_open()
        .map_err(|err| format!("failed to read open contradictions: {err}"))?;

    let mut affected_ids = BTreeSet::new();
    let mut conflict_edges = BTreeMap::<String, BTreeSet<String>>::new();
    for contradiction in contradictions {
        let left_active = active_by_id.contains_key(&contradiction.left_ref);
        let right_active = active_by_id.contains_key(&contradiction.right_ref);
        if !left_active && !right_active {
            continue;
        }
        if !(left_active && right_active) {
            return Err(format!(
                "open contradiction {} references unavailable memory and cannot be resolved for default context-pack use",
                contradiction.id
            ));
        }
        affected_ids.insert(contradiction.left_ref.clone());
        affected_ids.insert(contradiction.right_ref.clone());
        conflict_edges
            .entry(contradiction.left_ref.clone())
            .or_default()
            .insert(contradiction.right_ref.clone());
        conflict_edges
            .entry(contradiction.right_ref)
            .or_default()
            .insert(contradiction.left_ref);
    }

    if affected_ids.is_empty() {
        return Ok(());
    }

    let inputs = affected_ids
        .iter()
        .filter_map(|id| active_by_id.get(id.as_str()).copied())
        .map(|memory| {
            ConflictingMemoryInput::new(
                memory.id.to_string(),
                Some(memory.id.to_string()),
                memory.claim.clone(),
                AuthorityProofHint {
                    authority: authority_level(&memory.authority),
                    proof: ProofClosureHint::FullChainVerified,
                },
            )
            .with_conflicts(
                conflict_edges
                    .get(&memory.id.to_string())
                    .map(|ids| ids.iter().cloned().collect())
                    .unwrap_or_default(),
            )
        })
        .collect::<Vec<_>>();

    let output = resolve_conflicts(&inputs, &[]);
    output
        .require_default_use_allowed()
        .map_err(|err| format!("open contradiction blocks default context-pack use: {err}"))
}

fn authority_level(authority: &str) -> AuthorityLevel {
    match authority {
        "user" | "operator" => AuthorityLevel::High,
        "tool" | "system" => AuthorityLevel::Medium,
        _ => AuthorityLevel::Low,
    }
}