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};
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);
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());
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,
);
ipc::notify_best_effort(&Request::SkillInstalled {
skill_name,
rec_id: id_a.to_string(),
skill_path,
status: catalog::STATUS_PARAM_DRAFT.to_string(),
});
Ok(())
}
pub fn render_param_skill(report: &DiffReport, skill_name: &str) -> String {
let mut out = String::new();
let low = report.confidence == Confidence::Low;
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);
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);
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);
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(¶m.json_path),
param.step,
inline_safe(¶m.value_a),
inline_safe(¶m.value_b)
);
}
}
let _ = writeln!(out);
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, ¶ms);
let _ = writeln!(out, "{n}. **{}** — {line}", event.tool_name);
for note in notes {
let _ = writeln!(out, " - {note}");
}
n += 1;
}
}
let _ = writeln!(out);
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);
}
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
}
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 = ["{{", ¶m.name, "}}"].concat();
if !param.value_a.is_empty() && summary.contains(¶m.value_a) {
summary = summary.replace(¶m.value_a, &placeholder);
} else {
notes.push(format!(
"`{}` → `{placeholder}`",
inline_safe(¶m.json_path)
));
}
}
(format!("`{}`", inline_safe(&summary)), notes)
}
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}}"));
assert!(skill.contains("**Write** — `{{OUT}}`"));
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() {
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() {
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");
assert!(!skill.contains("`date`"));
assert!(!skill.contains("`whoami`"));
assert!(skill.contains("'date'") || skill.contains("'whoami'"));
}
}