//! Agent assembler — composes markdown from manifest + blocks. //! Output is deterministic: same manifest + blocks → byte-identical .md. use crate::manifest::Manifest; use crate::registry_client; use crate::substrate; use std::fs; use std::path::Path; pub fn assemble(m: &Manifest, blocks_dir: &Path) -> Result { // Substrate role expansion uses the kit root (parent of _blocks/). let root = blocks_dir .parent() .ok_or_else(|| "blocks_dir has no parent (can't locate _roles/ and _capabilities/)".to_string())?; let mut out = String::new(); write_frontmatter(m, &mut out); write_role(m, &mut out); write_substrate(m, root, &mut out)?; write_rule_blocks(m, &mut out)?; write_blocks(m, blocks_dir, &mut out)?; write_domain_scope(m, &mut out); write_handoffs(m, &mut out); write_output_format(m, &mut out); write_forbidden(m, &mut out); write_references(m, &mut out); Ok(out) } fn write_substrate(m: &Manifest, root: &Path, out: &mut String) -> Result<(), String> { let Some(role) = &m.substrate_role else { return Ok(()); }; let section = substrate::build_substrate_section(root, role)?; out.push_str(§ion); Ok(()) } fn write_frontmatter(m: &Manifest, out: &mut String) { let desc = m.description.replace('\n', " "); out.push_str("---\n"); out.push_str(&format!("name: {}\n", m.name)); out.push_str(&format!("description: {}\n", desc.trim())); out.push_str(&format!("tools: {}\n", m.tools.join(", "))); out.push_str(&format!("model: {}\n", m.model)); out.push_str("---\n\n"); out.push_str(&format!( "\n\n", m.name )); } fn write_role(m: &Manifest, out: &mut String) { out.push_str("# ROLE\n\n"); out.push_str(m.role.trim()); out.push_str("\n\n"); } fn write_blocks(m: &Manifest, blocks_dir: &Path, out: &mut String) -> Result<(), String> { for block in &m.blocks { let path = blocks_dir.join(format!("{block}.md")); let text = fs::read_to_string(&path) .map_err(|e| format!("read {}: {e}", path.display()))?; out.push_str(text.trim()); out.push_str("\n\n"); } Ok(()) } fn write_rule_blocks(m: &Manifest, out: &mut String) -> Result<(), String> { if m.rule_blocks.is_empty() { return Ok(()); } let db_path = registry_client::default_db_path(); if !db_path.exists() { eprintln!("warn [assembler]: registry not found at {} — skipping rule_blocks", db_path.display()); return Ok(()); } let conn = registry_client::open_read_only(&db_path)?; for name in &m.rule_blocks { match registry_client::find_rule(&conn, name) { Ok(Some(body)) => { out.push_str(&format!("\n")); out.push_str(body.trim()); out.push_str("\n\n\n"); } Ok(None) => { return Err(format!( "rule_block '{name}' not found in registry — \ run `kei-decompose decompose-rules` first or remove from manifest" )); } Err(e) => return Err(format!("registry lookup for '{name}': {e}")), } } Ok(()) } fn write_domain_scope(m: &Manifest, out: &mut String) { out.push_str("# DOMAIN SCOPE\n\n**In:**\n"); for item in &m.domain_in { out.push_str(&format!("- {item}\n")); } out.push_str("\n**Out (hand off):**\n"); for h in &m.handoff { out.push_str(&format!("- `{}` — {}\n", h.target, h.trigger)); } out.push('\n'); } fn write_handoffs(m: &Manifest, out: &mut String) { out.push_str("# HANDOFFS\n\n"); for h in &m.handoff { out.push_str(&format!("- **{}** — {}\n", h.target, h.trigger)); } out.push('\n'); } fn write_output_format(m: &Manifest, out: &mut String) { out.push_str("# OUTPUT FORMAT\n\n```\n"); out.push_str(&format!("=== {} REPORT ===\n", m.name.to_uppercase())); out.push_str("Goal: \n"); out.push_str("Scope: \n"); out.push_str("Plan: \n"); out.push_str("Executed: \n"); out.push_str("Verify: \n"); out.push_str("Evidence grades: \n"); out.push_str("Handoffs made: \n"); for extra in &m.output_extra_fields { out.push_str(extra); out.push('\n'); } out.push_str("Blockers / next: \n"); out.push_str("```\n\n"); } fn write_forbidden(m: &Manifest, out: &mut String) { out.push_str("# FORBIDDEN\n\n"); for item in &m.forbidden_domain { out.push_str(&format!("- {item}\n")); } out.push('\n'); } fn write_references(m: &Manifest, out: &mut String) { out.push_str("# REFERENCES\n\n"); out.push_str("- `~/.claude/CLAUDE.md` — baseline umbrella\n"); out.push_str("- `~/.claude/memory/MEMORY.md` — memory index (adjust if your Claude Code user-slug path differs)\n"); if let Some(mp) = &m.memory_project { out.push_str(&format!( "- `~/.claude/memory/{mp}` — project memory (adjust path if needed)\n" )); } if let Some(pc) = &m.project_claudemd { out.push_str(&format!("- `{pc}` — project CLAUDE.md\n")); } if let Some(refs) = &m.references { // Open registry for path-atom resolution. Missing DB is non-fatal — // references then fall through unchanged (advisory only). let conn = { let db_path = registry_client::default_db_path(); if db_path.exists() { registry_client::open_read_only(&db_path).ok() } else { None } }; for r in &refs.extra { let resolved = resolve_path_atom_ref(r, conn.as_ref()); out.push_str(&format!("- `{resolved}`\n")); } } } /// Resolve a `path:NAME/file.md` reference to an opaque content-addressed /// form `{path::NAME}/file.md` if `NAME` is a registered path-atom. /// /// Behaviour: /// - Input does not start with `path:` → return unchanged. /// - Input starts with `path:` but lookup fails (no atom / not a path-atom / /// no registry) → emit a stderr warning and return the input unchanged. /// This is advisory: the warn surfaces typos in manifests but never /// blocks rendering. /// - Lookup succeeds → return `{path::NAME}/`. fn resolve_path_atom_ref(r: &str, conn: Option<&rusqlite::Connection>) -> String { let Some(rest) = r.strip_prefix("path:") else { return r.to_string(); }; let (name, suffix) = match rest.split_once('/') { Some((n, s)) => (n, s), // `path:NAME` with no `/file` part — atom-only ref. Resolve to // `{path::NAME}` so the public output is still opaque. None => (rest, ""), }; let Some(c) = conn else { eprintln!( "warn [assembler]: 'path:{name}' reference but registry DB not open — passing through" ); return r.to_string(); }; match registry_client::is_path_atom(c, name) { Ok(true) => { if suffix.is_empty() { format!("{{path::{name}}}") } else { format!("{{path::{name}}}/{suffix}") } } Ok(false) => { eprintln!( "warn [assembler]: 'path:{name}' not found in registry as path-atom — passing through" ); r.to_string() } Err(e) => { eprintln!("warn [assembler]: registry lookup for path-atom '{name}': {e}"); r.to_string() } } } #[cfg(test)] mod write_references_tests { use super::resolve_path_atom_ref; #[test] fn passthrough_non_path_ref() { let r = "~/.claude/memory/foo.md"; assert_eq!(resolve_path_atom_ref(r, None), r); } #[test] fn passthrough_path_ref_when_no_db() { // No registry conn → emit warn (suppressed in test) + passthrough. let r = "path:user-memory/foo.md"; assert_eq!(resolve_path_atom_ref(r, None), r); } }