use crate::{audit, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::Path;
const DEFAULT_MAX_GATE_RETRIES: u32 = 3;
const DEFAULT_MAX_RUN_ATTEMPTS: u32 = 2;
#[derive(Debug, Deserialize)]
struct FrontMatter {
slug: String,
#[serde(default)]
scope: Vec<String>,
#[serde(default)]
title: Option<String>,
#[serde(default = "default_max_gate_retries")]
max_gate_retries: u32,
#[serde(default = "default_max_run_attempts")]
max_run_attempts: u32,
}
fn default_max_gate_retries() -> u32 {
DEFAULT_MAX_GATE_RETRIES
}
fn default_max_run_attempts() -> u32 {
DEFAULT_MAX_RUN_ATTEMPTS
}
#[derive(Debug)]
struct Recipe {
front: FrontMatter,
description: String,
}
fn parse_recipe(path: &Path) -> Result<Recipe> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading recipe {}", path.display()))?;
let rest = text
.trim_start_matches('\u{feff}')
.strip_prefix("---\n")
.ok_or_else(|| {
anyhow::anyhow!("recipe must start with a `---` front-matter block (slug, scope)")
})?;
let end = rest
.find("\n---")
.ok_or_else(|| anyhow::anyhow!("recipe's front matter has no closing `---`"))?;
let yaml = &rest[..end];
let description = rest[end + 4..].trim_start_matches('\n').trim().to_string();
if description.is_empty() {
anyhow::bail!("recipe has no description below the front matter — that's the part the agent actually reads");
}
let front: FrontMatter = serde_yaml::from_str(yaml)
.context("parsing recipe front matter (need at least `slug:`)")?;
Ok(Recipe { front, description })
}
#[derive(Debug, Deserialize)]
struct NextSpec {
slug: String,
stage: String,
command: String,
complete: bool,
}
#[derive(Debug, Deserialize, Default)]
struct NextReport {
#[serde(default)]
specs: Vec<NextSpec>,
}
fn exec_capture(name: &str, m: &Manifest, argv: &[String]) -> Result<(bool, String)> {
let container = m.sandbox_container();
let mut args: Vec<&str> = vec!["exec", &container];
args.extend(argv.iter().map(|s| s.as_str()));
let (status, out) = proc::run_capture_combined("docker", &args)?;
audit::log_exec(name, m, "recipe", argv, status.code())?;
Ok((status.success(), out))
}
fn next_report(name: &str, m: &Manifest, slug: &str) -> Result<NextReport> {
let (_, out) = exec_capture(
name,
m,
&["keel".into(), "next".into(), "--json".into(), slug.into()],
)?;
serde_json::from_str(&out)
.with_context(|| format!("parsing `keel next --json {slug}` output:\n{out}"))
}
fn author_spec(name: &str, m: &Manifest, slug: &str, description: &str) -> Result<()> {
let (_, keel_prompt) = exec_capture(
name,
m,
&["keel".into(), "spec".into(), "prompt".into(), slug.into()],
)?;
let full = format!(
"What the operator wants (context only — this step authors the spec, it does not implement anything):\n{description}\n\n{keel_prompt}"
);
let argv = vec![
"claude".into(),
"--print".into(),
full,
"--allowedTools".into(),
"Write".into(),
];
let (ok, out) = exec_capture(name, m, &argv)?;
println!("{out}");
if !ok {
anyhow::bail!("spec-authoring agent call failed");
}
Ok(())
}
fn drive_gate(
name: &str,
m: &Manifest,
gate_argv: &[String],
slug: &str,
max_retries: u32,
) -> Result<bool> {
for attempt in 0..=max_retries {
let (ok, out) = exec_capture(name, m, gate_argv)?;
println!("{out}");
if ok {
return Ok(true);
}
if attempt == max_retries {
return Ok(false);
}
println!(
"==> gate failed (attempt {}/{}) — asking the agent to fix exactly that",
attempt + 1,
max_retries + 1
);
let fix_prompt = format!(
"The following keel gate failed for spec '{slug}'. Using only your Edit tool, fix exactly what it names below — do not touch any other file, do not implement the feature, do not run any command.\n\n{out}"
);
let claude_argv = vec![
"claude".into(),
"--print".into(),
fix_prompt,
"--allowedTools".into(),
"Edit".into(),
];
let (fixed_ok, fix_out) = exec_capture(name, m, &claude_argv)?;
println!("{fix_out}");
if !fixed_ok {
return Ok(false);
}
}
Ok(false)
}
pub fn run(name: &str, recipe_path: &Path) -> Result<()> {
let m = Manifest::load(&paths::manifest_path(name)?)?;
crate::secrets::resolve_into_env(name, &m.secrets);
let recipe = parse_recipe(recipe_path)?;
let slug = recipe.front.slug.clone();
let title = recipe
.front
.title
.clone()
.unwrap_or_else(|| slug.replace('-', " "));
println!("==> recipe '{slug}' for project '{name}'");
let mut new_argv = vec![
"keel".into(),
"spec".into(),
"new".into(),
slug.clone(),
"--title".into(),
title,
];
for g in &recipe.front.scope {
new_argv.push("--scope".into());
new_argv.push(g.clone());
}
let (_, out) = exec_capture(name, &m, &new_argv)?;
let already_existed = out.contains("already exists");
let freshly_created = out.contains("created .keel/specs/");
if !already_existed && !freshly_created {
println!("{out}");
anyhow::bail!("`keel spec new` failed — see output above");
}
if freshly_created {
println!("==> scaffolded .keel/specs/{slug}/ — authoring the spec now");
author_spec(name, &m, &slug, &recipe.description)?;
}
let mut run_attempts = 0u32;
loop {
let report = next_report(name, &m, &slug)?;
let Some(spec) = report.specs.iter().find(|s| s.slug == slug) else {
println!("keel no longer lists '{slug}' — nothing left to drive.");
return Ok(());
};
if spec.complete {
println!("==> '{slug}' is complete.");
return Ok(());
}
println!("==> [{slug}] stage: {} next: {}", spec.stage, spec.command);
if spec.stage.contains("approval") {
println!(
"\nPAUSED for human approval. Review the change, then run:\n\n moor run {name} -- {}\n\n...and re-run this recipe to continue.",
spec.command
);
return Ok(());
}
let argv: Vec<String> = spec.command.split_whitespace().map(String::from).collect();
let head = (
argv.first().map(String::as_str),
argv.get(1).map(String::as_str),
);
match head {
(Some("keel"), Some("gate")) => {
if !drive_gate(name, &m, &argv, &slug, recipe.front.max_gate_retries)? {
anyhow::bail!(
"gate kept failing after {} attempt(s) — see .keel/specs/{slug}/gates/ for evidence; fix by hand, then re-run this recipe",
recipe.front.max_gate_retries + 1
);
}
}
(Some("keel"), Some("run")) => {
run_attempts += 1;
if run_attempts > recipe.front.max_run_attempts {
anyhow::bail!(
"`keel run` still hasn't reached a human checkpoint after {} attempt(s) — see .keel/runs/ for evidence; drive it by hand, then re-run this recipe",
recipe.front.max_run_attempts
);
}
let (_, out) = exec_capture(name, &m, &argv)?;
println!("{out}");
}
_ => {
let (ok, out) = exec_capture(name, &m, &argv)?;
println!("{out}");
if !ok {
anyhow::bail!("`{}` failed — see output above", spec.command);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_temp(label: &str, content: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("moor-recipe-test-{label}-{nanos}.md"));
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn parses_front_matter_and_description() {
let path = write_temp(
"ok",
"---\nslug: greet-function\nscope:\n - \"src/**\"\n---\n\nI want a greet function.\n",
);
let recipe = parse_recipe(&path).unwrap();
assert_eq!(recipe.front.slug, "greet-function");
assert_eq!(recipe.front.scope, vec!["src/**".to_string()]);
assert_eq!(recipe.front.max_gate_retries, DEFAULT_MAX_GATE_RETRIES);
assert_eq!(recipe.front.max_run_attempts, DEFAULT_MAX_RUN_ATTEMPTS);
assert_eq!(recipe.description, "I want a greet function.");
let _ = std::fs::remove_file(&path);
}
#[test]
fn honors_explicit_overrides() {
let path = write_temp(
"overrides",
"---\nslug: x\ntitle: Custom Title\nmax_gate_retries: 5\nmax_run_attempts: 1\n---\nDo the thing.\n",
);
let recipe = parse_recipe(&path).unwrap();
assert_eq!(recipe.front.title.as_deref(), Some("Custom Title"));
assert_eq!(recipe.front.max_gate_retries, 5);
assert_eq!(recipe.front.max_run_attempts, 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_missing_opening_front_matter() {
let path = write_temp("no-open", "slug: x\n---\nsomething\n");
assert!(parse_recipe(&path).is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_missing_closing_front_matter() {
let path = write_temp(
"no-close",
"---\nslug: x\ndescription with no closing marker\n",
);
assert!(parse_recipe(&path).is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_empty_description() {
let path = write_temp("empty-body", "---\nslug: x\n---\n\n \n");
let err = parse_recipe(&path).unwrap_err();
assert!(err.to_string().contains("no description"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_missing_slug() {
let path = write_temp("no-slug", "---\nscope: []\n---\nDo something.\n");
assert!(parse_recipe(&path).is_err());
let _ = std::fs::remove_file(&path);
}
}