skillnet 0.4.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
use std::{
    collections::BTreeMap,
    fs,
    path::Path,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{bail, Context};
use uuid::Uuid;

use super::{
    catalog::ThresholdStore,
    eval, meta_cmd, plan_parser, shape_hash,
    sidecar::{PhaseRecord, PlanRecord, Sidecar, TriggerRecord},
    Db,
};

const SIDECAR_FILE: &str = ".calibration.json";

pub struct InitOptions {
    pub stdout: bool,
    pub force: bool,
}

pub fn run(db: &Db, plan_dir: &Path, options: InitOptions) -> anyhow::Result<()> {
    let sidecar = build_sidecar(db, plan_dir, options.force)?;
    let rendered = serde_json::to_string_pretty(&sidecar).context("failed to serialize sidecar")?;
    if options.stdout {
        println!("{rendered}");
        return Ok(());
    }

    let path = plan_dir.join(SIDECAR_FILE);
    if path.exists() && !options.force {
        bail!(
            "refusing to overwrite existing sidecar {}; pass --force to replace it",
            path.display()
        );
    }
    fs::write(&path, format!("{rendered}\n"))
        .with_context(|| format!("failed to write sidecar {}", path.display()))?;
    println!("wrote {}", path.display());
    Ok(())
}

pub fn build_sidecar(db: &Db, plan_dir: &Path, force: bool) -> anyhow::Result<Sidecar> {
    let previous = if force {
        load_existing_sidecar(plan_dir)?
    } else {
        None
    };
    let plan = plan_parser::parse(plan_dir)?;
    let store = ThresholdStore::load(db)?;
    let triggers = eval::evaluate_triggers(&plan, &store)?
        .into_iter()
        .map(|row| TriggerRecord {
            name: row.name,
            input_value: row.input_value,
            threshold: row.threshold,
            fired: row.fired,
            section_added: row.section_added,
        })
        .collect::<Vec<_>>();
    let meta = meta_cmd::evaluate(db, plan_dir, None)?;
    let sidecar = Sidecar {
        schema_version: 1,
        plan: PlanRecord {
            id: previous
                .as_ref()
                .map(|sidecar| sidecar.plan.id.clone())
                .unwrap_or_else(|| Uuid::new_v4().to_string()),
            name: plan.name.clone(),
            flavor: plan.flavor.clone(),
            worktype: plan.worktype.clone(),
            created_at: if let Some(created_at) =
                previous.as_ref().map(|sidecar| sidecar.plan.created_at)
            {
                created_at
            } else {
                unix_timestamp()?
            },
            phase_count: plan.phase_count,
            wave_count: plan.wave_count,
            max_chain_depth: plan.max_chain_depth,
            repo_spread: plan.repo_spread,
            routing_dist: plan.routing_dist.clone(),
            shape_hash: shape_hash::compute(&plan),
        },
        triggers,
        phases: plan
            .phases
            .iter()
            .map(|phase| PhaseRecord {
                ordinal: phase.ordinal,
                slug: phase.slug.clone(),
                routing_tier: phase.routing_tier.clone(),
                files: phase.files.clone(),
            })
            .collect(),
        meta_heuristics_fired: meta.fired,
        tags: merged_tags(&plan, previous.as_ref()),
        verify: previous.and_then(|sidecar| sidecar.verify),
    };
    Ok(sidecar)
}

fn load_existing_sidecar(plan_dir: &Path) -> anyhow::Result<Option<Sidecar>> {
    let path = plan_dir.join(SIDECAR_FILE);
    if !path.exists() {
        return Ok(None);
    }
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("failed to read existing sidecar {}", path.display()))?;
    serde_json::from_str(&raw)
        .map(Some)
        .with_context(|| format!("malformed existing sidecar {}", path.display()))
}

fn merged_tags(
    plan: &super::catalog::PlanInputs,
    previous: Option<&Sidecar>,
) -> BTreeMap<String, String> {
    let mut tags = auto_tags(plan);
    if let Some(previous) = previous {
        tags.extend(previous.tags.clone());
    }
    tags
}

fn auto_tags(plan: &super::catalog::PlanInputs) -> BTreeMap<String, String> {
    let mut tags = BTreeMap::new();
    tags.insert("flavor".to_string(), plan.flavor.clone());
    if let Some(worktype) = &plan.worktype {
        tags.insert("worktype".to_string(), worktype.clone());
    }
    tags.insert(
        "scope".to_string(),
        match plan.repo_spread {
            0 | 1 => "single-repo",
            2 => "multi-repo",
            _ => "cross-org",
        }
        .to_string(),
    );
    tags.insert(
        "risk".to_string(),
        if plan.routing_dist.get("max").copied().unwrap_or(0) > 0 {
            "high"
        } else if plan.routing_dist.get("high").copied().unwrap_or(0) > 0 {
            "mixed"
        } else {
            "low"
        }
        .to_string(),
    );
    tags
}

fn unix_timestamp() -> anyhow::Result<i64> {
    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is before unix epoch")?;
    i64::try_from(duration.as_secs()).context("unix timestamp does not fit in i64")
}