galdr 0.16.0

Record & Replay for agent skills — capture a session's tool calls and distill them into a reproducible skill. Local-first.
//! Renders a parametrized `SKILL.md` from a two-recording diff.
//!
//! Where `distill` turns one recording into a skill, this turns *two* runs of the
//! same task into a skill whose varying inputs are named parameters. galdr stays
//! the only writer of the skills directory: `--emit` installs through the same
//! sanctioned path. Without `--emit` it just prints the diff report.

use std::fmt::Write as _;

use anyhow::Result;

use crate::diff::{self, Confidence, DiffReport, Parameter};
use crate::ipc::Request;
use crate::span::Event;
use crate::summary::{slugify, summarize_event};
use crate::{catalog, ipc, paths, record};

/// Diffs two recordings and, with `emit`, installs the parametrized skill;
/// otherwise prints the report.
pub fn parametrize(id_a: &str, id_b: &str, emit: bool) -> Result<()> {
    let report = diff::compute(id_a, id_b)?;

    if !emit {
        print!("{}", diff::render_report(&report));
        println!("\n(use --emit to write the parametrized SKILL.md)");
        return Ok(());
    }

    let skill_name = format!("galdr-{}-param", slugify(&report.name_a));
    let content = render_param_skill(&report, &skill_name);

    // A parametrized skill is a draft (it carries DRAFT/TODO markers a human finishes),
    // so the gate skips Practicality — but it still keeps the full Security axis, since
    // the emitted file is one a human is about to open and an agent may later read.
    let ctx = crate::validate::ValidationCtx::new(true, false);
    crate::distill::gate_or_bail(&content, &ctx)?;

    let dir = paths::skill_dir(&skill_name)?;
    paths::ensure_not_symlinked(&dir)?;
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("SKILL.md");
    std::fs::write(&path, content)?;
    println!("Parametrized skill written to {}", path.display());

    // Make it discoverable in every installed harness, like a distilled skill.
    if let Ok(results) = crate::link::link_skill(&skill_name) {
        let reached: Vec<&str> = results
            .iter()
            .filter(|r| {
                r.status != crate::link::LinkStatus::Conflict
                    && r.status != crate::link::LinkStatus::Failed
            })
            .map(|r| r.harness.as_str())
            .collect();
        if !reached.is_empty() {
            println!("Discoverable in: {}", reached.join(", "));
        }
    }

    let skill_path = path.display().to_string();
    let installed_at = record::now_rfc3339();
    let _ = catalog::sync_installed_skill(
        &skill_name,
        Some(id_a),
        &skill_path,
        Some(&installed_at),
        catalog::STATUS_PARAM_DRAFT,
    );

    // Best-effort provenance; the "A" recording is the primary procedure.
    ipc::notify_best_effort(&Request::SkillInstalled {
        skill_name,
        rec_id: id_a.to_string(),
        skill_path,
        status: catalog::STATUS_PARAM_DRAFT.to_string(),
    });
    Ok(())
}

/// Composes the parametrized `SKILL.md`.
pub fn render_param_skill(report: &DiffReport, skill_name: &str) -> String {
    let mut out = String::new();
    let low = report.confidence == Confidence::Low;

    // Frontmatter.
    let _ = writeln!(out, "---");
    let _ = writeln!(out, "name: {skill_name}");
    let _ = writeln!(
        out,
        "description: \"[galdr DRAFT] Parametrized from two recordings of \\\"{}\\\". Varying inputs are named parameters; the agent must sharpen this description.\"",
        report.name_a
    );
    let _ = writeln!(out, "---");
    let _ = writeln!(out);

    let _ = writeln!(out, "# {skill_name}");
    let _ = writeln!(out);
    if low {
        let _ = writeln!(
            out,
            "> ⚠ LOW-CONFIDENCE — the two recordings did not align cleanly. The parameter mapping below is a guess, not a 1:1 match. Read the alignment notes and verify before trusting this skill."
        );
        let _ = writeln!(out);
    }
    let _ = writeln!(
        out,
        "> Draft generated by `galdr parametrize` from two recordings. This is the scaffolding, not the final skill: the agent completes the marked sections."
    );
    let _ = writeln!(out);

    // Provenance.
    let _ = writeln!(out, "## Provenance");
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "- recording A: \"{}\" ({} steps)",
        report.name_a,
        report.events_a.len()
    );
    let _ = writeln!(
        out,
        "- recording B: \"{}\" ({} steps)",
        report.name_b,
        report.events_b.len()
    );
    let conf = if low { "LOW" } else { "HIGH" };
    let total = report.events_a.len().max(report.events_b.len()).max(1);
    let _ = writeln!(
        out,
        "- alignment: {conf} confidence, {}/{total} steps matched",
        report.matched
    );
    let _ = writeln!(out);

    // Goal (agent-completed).
    let _ = writeln!(out, "## Goal");
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "<!-- TODO(agent): one or two sentences on WHAT this skill achieves and WHEN to use it. -->"
    );
    let _ = writeln!(out);

    // Parameters.
    let _ = writeln!(out, "## Parameters");
    let _ = writeln!(out);
    if report.parameters.is_empty() {
        let _ = writeln!(
            out,
            "_(no varying inputs found — the two runs were identical where they aligned)_"
        );
    } else {
        for param in &report.parameters {
            let _ = writeln!(
                out,
                "- `{{{{{}}}}}` — {} `{}` at step {} (e.g. `{}` / `{}`)",
                param.name,
                param.tool_name,
                inline_safe(&param.json_path),
                param.step,
                inline_safe(&param.value_a),
                inline_safe(&param.value_b)
            );
        }
    }
    let _ = writeln!(out);

    // Procedure (parametrized).
    let _ = writeln!(out, "## Procedure (parametrized)");
    let _ = writeln!(out);
    if report.matched == 0 {
        let _ = writeln!(out, "_(the recordings share no aligned steps)_");
    } else {
        let mut n = 1;
        for step in &report.alignment {
            let Some(ia) = step.a else { continue };
            if !step.matched {
                continue;
            }
            let event = &report.events_a[ia];
            let params: Vec<&Parameter> = report
                .parameters
                .iter()
                .filter(|p| p.step == ia + 1)
                .collect();
            let (line, notes) = templated_step(event, &params);
            let _ = writeln!(out, "{n}. **{}** — {line}", event.tool_name);
            for note in notes {
                let _ = writeln!(out, "    - {note}");
            }
            n += 1;
        }
    }
    let _ = writeln!(out);

    // Alignment notes for low confidence.
    if low && !report.notes.is_empty() {
        let _ = writeln!(out, "## Alignment notes");
        let _ = writeln!(out);
        for note in &report.notes {
            let _ = writeln!(out, "- {note}");
        }
        let _ = writeln!(out);
    }

    // Agent-completed tail, matching the distill conventions.
    let _ = writeln!(out, "## Success criteria");
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "<!-- TODO(agent): how to verify the task came out right for given parameter values. -->"
    );
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "Delete the DRAFT markers and this comment once the skill is sharpened."
    );

    out
}

/// Renders one step's summary with parameter values replaced by `{{NAME}}`
/// placeholders. Values that do not appear literally in the summary are listed as
/// trailing notes instead.
fn templated_step(event: &Event, params: &[&Parameter]) -> (String, Vec<String>) {
    let mut summary = summarize_event(event);
    let mut notes = Vec::new();
    for param in params {
        let placeholder = ["{{", &param.name, "}}"].concat();
        if !param.value_a.is_empty() && summary.contains(&param.value_a) {
            summary = summary.replace(&param.value_a, &placeholder);
        } else {
            notes.push(format!(
                "`{}` → `{placeholder}`",
                inline_safe(&param.json_path)
            ));
        }
    }
    // Sanitize last so any inserted `{{NAME}}` placeholders survive untouched.
    (format!("`{}`", inline_safe(&summary)), notes)
}

/// Makes a value safe to embed in a backtick-delimited inline code span: a stray
/// backtick would close the span and a newline would break the markdown list item,
/// so both are neutralized. Draft skills are read by the agent, so a faithful but
/// well-formed rendering beats a literal one that corrupts the document.
fn inline_safe(value: &str) -> String {
    value.replace('`', "'").replace(['\n', '\r'], " ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diff::analyze;

    fn ev(seq: u64, tool: &str, input: serde_json::Value) -> Event {
        Event {
            ts: "2026-06-19T00:00:00Z".into(),
            seq,
            tool_name: tool.into(),
            tool_input: input,
            tool_response: serde_json::json!({}),
            cwd: None,
            session_id: None,
            event_kind: crate::span::EventKind::ToolCall,
            human: None,
        }
    }

    #[test]
    fn high_confidence_skill_has_parameters_and_template() {
        let a = vec![
            ev(0, "Bash", serde_json::json!({ "command": "git status" })),
            ev(1, "Write", serde_json::json!({ "file_path": "/a/out.md" })),
        ];
        let b = vec![
            ev(0, "Bash", serde_json::json!({ "command": "git status" })),
            ev(1, "Write", serde_json::json!({ "file_path": "/b/out.md" })),
        ];
        let report = analyze("ship", &a, "ship", &b);
        let skill = render_param_skill(&report, "galdr-ship-param");

        assert!(skill.contains("## Parameters"));
        assert!(skill.contains("## Procedure (parametrized)"));
        assert!(skill.contains("{{OUT}}"));
        // The output path is templated inside the Write step line.
        assert!(skill.contains("**Write** — `{{OUT}}`"));
        // A clean alignment must not carry the low-confidence banner.
        assert!(!skill.contains("LOW-CONFIDENCE"));
    }

    #[test]
    fn low_confidence_skill_carries_banner_and_notes() {
        let a = vec![
            ev(0, "Bash", serde_json::json!({ "command": "git status" })),
            ev(1, "Read", serde_json::json!({ "file_path": "/a.rs" })),
        ];
        let b = vec![ev(0, "Glob", serde_json::json!({ "pattern": "*.rs" }))];
        let report = analyze("a", &a, "b", &b);
        let skill = render_param_skill(&report, "galdr-a-param");

        assert!(skill.contains("⚠ LOW-CONFIDENCE"));
        assert!(skill.contains("## Alignment notes"));
    }

    #[test]
    fn parametrize_output_passes_gate() {
        // The emitted parametrized skill is a draft (DRAFT/TODO markers), so the gate
        // skips Practicality but keeps Security: with non-personal values it installs.
        let a = vec![
            ev(0, "Bash", serde_json::json!({ "command": "git status" })),
            ev(
                1,
                "Write",
                serde_json::json!({ "file_path": "/repo/out.md" }),
            ),
        ];
        let b = vec![
            ev(0, "Bash", serde_json::json!({ "command": "git status" })),
            ev(
                1,
                "Write",
                serde_json::json!({ "file_path": "/other/out.md" }),
            ),
        ];
        let report = analyze("ship", &a, "ship", &b);
        let skill = render_param_skill(&report, "galdr-ship-param");
        let ctx = crate::validate::ValidationCtx::new(true, false);
        let v = crate::validate::validate_skill(&skill, &ctx);
        assert!(!v.has_blocking(false), "{v}\n{skill}");
    }

    #[test]
    fn inline_safe_neutralizes_backticks_and_newlines() {
        assert_eq!(inline_safe("a`b`c"), "a'b'c");
        assert_eq!(inline_safe("line1\nline2"), "line1 line2");
        assert_eq!(inline_safe("plain"), "plain");
    }

    #[test]
    fn parameter_values_with_backticks_do_not_break_the_markdown() {
        // A command substitution like `date` in a Bash arg would otherwise close the
        // inline-code span and corrupt the generated skill.
        let a = vec![ev(
            0,
            "Bash",
            serde_json::json!({ "command": "echo `date`" }),
        )];
        let b = vec![ev(
            0,
            "Bash",
            serde_json::json!({ "command": "echo `whoami`" }),
        )];
        let report = analyze("t", &a, "t", &b);
        let skill = render_param_skill(&report, "galdr-t-param");
        // The recorded backtick-wrapped substitutions must not survive verbatim —
        // they would close the inline-code span and corrupt the document.
        assert!(!skill.contains("`date`"));
        assert!(!skill.contains("`whoami`"));
        // They survive as neutralized single-quoted forms instead.
        assert!(skill.contains("'date'") || skill.contains("'whoami'"));
    }
}