diff --git a/_assembler/src/validator.rs b/_assembler/src/validator.rs index 5fb3c69..e59864c 100644 --- a/_assembler/src/validator.rs +++ b/_assembler/src/validator.rs @@ -34,5 +34,120 @@ pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> { return Err("role must not be empty".into()); } + check_no_placeholders(m)?; + Ok(()) } + +/// Reject manifests that still carry `{{PLACEHOLDER}}` tokens — the wizard +/// should have substituted them. Emitted literally into generated .md they +/// produce broken agents. Matches `{{...}}` conservatively (not single braces). +fn check_no_placeholders(m: &Manifest) -> Result<(), String> { + let check = |field: &str, value: &str| -> Result<(), String> { + if contains_placeholder(value) { + Err(format!( + "Unsubstituted template placeholder in field '{field}': {value}. Did the wizard skip a substitution?" + )) + } else { + Ok(()) + } + }; + + check("name", &m.name)?; + check("description", &m.description)?; + check("model", &m.model)?; + check("role", &m.role)?; + for (i, t) in m.tools.iter().enumerate() { + check(&format!("tools[{i}]"), t)?; + } + for (i, b) in m.blocks.iter().enumerate() { + check(&format!("blocks[{i}]"), b)?; + } + for (i, d) in m.domain_in.iter().enumerate() { + check(&format!("domain_in[{i}]"), d)?; + } + for (i, d) in m.forbidden_domain.iter().enumerate() { + check(&format!("forbidden_domain[{i}]"), d)?; + } + for (i, h) in m.handoff.iter().enumerate() { + check(&format!("handoff[{i}].target"), &h.target)?; + check(&format!("handoff[{i}].trigger"), &h.trigger)?; + } + for (i, o) in m.output_extra_fields.iter().enumerate() { + check(&format!("output_extra_fields[{i}]"), o)?; + } + if let Some(v) = &m.memory_project { + check("memory_project", v)?; + } + if let Some(v) = &m.project_claudemd { + check("project_claudemd", v)?; + } + if let Some(r) = &m.references { + for (i, e) in r.extra.iter().enumerate() { + check(&format!("references.extra[{i}]"), e)?; + } + } + Ok(()) +} + +fn contains_placeholder(s: &str) -> bool { + // Look for a `{{` with a matching `}}` later in the same string. + if let Some(start) = s.find("{{") { + if s[start + 2..].contains("}}") { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{Handoff, Manifest}; + + fn base() -> Manifest { + Manifest { + name: "test".into(), + description: "d".into(), + tools: vec!["Read".into()], + model: "opus".into(), + role: "r".into(), + blocks: vec!["baseline".into(), "evidence-grading".into(), "memory-protocol".into()], + domain_in: vec!["x".into()], + forbidden_domain: vec!["y".into()], + handoff: vec![Handoff { target: "a".into(), trigger: "b".into() }], + output_extra_fields: vec![], + memory_project: None, + project_claudemd: None, + references: None, + } + } + + #[test] + fn rejects_placeholder_in_memory_project() { + let mut m = base(); + m.memory_project = Some("{{MEMORY_PROJECT}}".into()); + let err = check_no_placeholders(&m).unwrap_err(); + assert!(err.contains("memory_project"), "err = {err}"); + assert!(err.contains("{{MEMORY_PROJECT}}"), "err = {err}"); + } + + #[test] + fn accepts_single_braces() { + let mut m = base(); + m.description = "hello {world}".into(); + assert!(check_no_placeholders(&m).is_ok()); + } + + #[test] + fn accepts_empty_manifest() { + assert!(check_no_placeholders(&base()).is_ok()); + } + + #[test] + fn rejects_placeholder_in_role() { + let mut m = base(); + m.role = "do {{THING}}".into(); + assert!(check_no_placeholders(&m).is_err()); + } +}