use crate::{audit, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
const DEFAULT_MAX_GATE_RETRIES: u32 = 3;
const DEFAULT_MAX_RUN_ATTEMPTS: u32 = 2;
#[derive(Debug, Deserialize)]
pub struct FrontMatter {
pub 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)]
pub struct Recipe {
pub front: FrontMatter,
pub description: String,
}
pub 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 log_event(name: &str, event: &str, mut data: serde_json::Value) -> Result<()> {
if let Some(obj) = data.as_object_mut() {
obj.insert("event".to_string(), json!(event));
}
let path = paths::chain_log_path(name)?;
audit::append_chained(&path, "recipe-event", data)?;
Ok(())
}
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 the pipeline status for {slug}:\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 mut argv = vec![
"claude".into(),
"--print".into(),
full,
"--allowedTools".into(),
"Write".into(),
];
argv.extend(crate::session::deny_shell_argv());
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}");
log_event(
name,
"gate-attempt",
json!({"slug": slug, "gate": gate_argv, "attempt": attempt + 1, "max": max_retries + 1, "result": if ok { "pass" } else { "fail" }}),
)?;
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 mut claude_argv = vec![
"claude".into(),
"--print".into(),
fix_prompt,
"--allowedTools".into(),
"Edit".into(),
];
claude_argv.extend(crate::session::deny_shell_argv());
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}'");
log_event(name, "recipe-start", json!({"slug": slug}))?;
if crate::manifest::validate_name(&slug).is_ok() {
std::fs::write(paths::active_spec_path(name)?, &slug)?;
println!("==> '{slug}' is now the active spec");
}
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!("creating the spec failed — see the output above");
}
if freshly_created {
println!("==> created spec '{slug}' — writing it now");
log_event(name, "spec-scaffolded", json!({"slug": slug}))?;
author_spec(name, &m, &slug, &recipe.description)?;
log_event(name, "spec-authored", json!({"slug": slug}))?;
} else {
log_event(name, "spec-resumed", json!({"slug": slug}))?;
}
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!("'{slug}' is no longer in the pipeline — nothing left to drive.");
return Ok(());
};
if spec.complete {
println!("==> '{slug}' is complete.");
log_event(name, "complete", json!({"slug": slug}))?;
return Ok(());
}
println!("==> [{slug}] stage: {} next: {}", spec.stage, spec.command);
log_event(
name,
"stage",
json!({"slug": slug, "stage": spec.stage, "command": spec.command}),
)?;
if spec.stage.contains("approval") {
let project = if paths::read_current_project().ok().flatten().as_deref() == Some(name) {
String::new()
} else {
format!(" --project {name}")
};
println!(
"\nPAUSED for your approval. Review it and decide:\n\n moor approve{project} (shows it first, then asks)\n moor reject{project} \"why\"\n\n...then re-run this recipe to continue."
);
log_event(
name,
"paused-for-approval",
json!({"slug": slug, "stage": spec.stage}),
)?;
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)? {
log_event(
name,
"failed",
json!({"slug": slug, "reason": "gate kept failing"}),
)?;
anyhow::bail!(
"a check kept failing after {} attempt(s) — see .keel/specs/{slug}/gates/ for evidence; fix it by hand, then re-run this recipe",
recipe.front.max_gate_retries + 1
);
}
}
(Some("keel"), Some("run")) => {
run_attempts += 1;
log_event(
name,
"run-attempt",
json!({"slug": slug, "attempt": run_attempts, "max": recipe.front.max_run_attempts}),
)?;
if run_attempts > recipe.front.max_run_attempts {
log_event(
name,
"failed",
json!({"slug": slug, "reason": "keel run attempt cap reached"}),
)?;
anyhow::bail!(
"the build still hasn't reached a point that needs you after {} attempt(s) — see .keel/runs/ for evidence; continue with `moor go`, 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 {
log_event(
name,
"failed",
json!({"slug": slug, "reason": format!("`{}` failed", spec.command)}),
)?;
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);
}
}