feat: zeroclaw agentic agency architecture — agents, skills, Rust crates

- 18 agent specs across Software, Support, Marketing, Cross-cutting
  (orchestrator + 7 software + 5 support + 3 cross-cutting + leads-finder)
- 8 reusable skills (handoff, pr-review, dokploy-deploy, campaign-structure,
  content-calendar, ticket-triage, license-lifecycle, invoice-draft)
- 3 JSON schemas: handoff, deliverable, approval
- Policies: autonomy levels, tool risk classification, compliance (DSGVO/GoBD/UWG)
- routing.toml: model tiers T1/T2/T3 + provider config (PII gating)
- Rust workspace (5 crates, all compile + 16 tests pass):
  - aos-mcp: tool registry + risk tagging
  - aos-policy: autonomy levels, budget enforcement, approval requests
  - aos-license: ed25519 license issue/verify (real crypto + CLI binary)
  - aos-ledger: integer-cents money math, gapless numbering, VAT, audit chain
  - aos-sync: frontmatter validator (18/18 specs pass), CLI binary
- CLAUDE.md entry point with architecture overview + roadmap
This commit is contained in:
Z User committed 2026-10-30 13:01:03 +00:00
1 parent d18c463ffd
commit 136379c666
60 files changed
+4776

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
//! Agent spec parsing + validation.
//!
//! An agent spec is a markdown file with YAML frontmatter. The required
//! fields mirror the format documented in `CLAUDE.md`. Validation fails fast
//! on missing fields so a broken spec never reaches a runtime.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
use walkdir::WalkDir;
#[derive(Debug, Error)]
pub enum SpecError {
#[error("missing required field '{0}' in {1}")]
MissingField(&'static str, String),
#[error("could not read file {0}: {1}")]
Io(String, String),
#[error("could not parse frontmatter in {0}: {1}")]
Parse(String, String),
}
/// The frontmatter of an agent spec. Mirrors `agents/<dept>/<name>.md`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSpec {
pub name: String,
pub tier: String,
pub department: String,
pub reports_to: String,
pub autonomy: String,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub forbidden: Vec<String>,
#[serde(default)]
pub budget: Budget,
#[serde(default)]
pub language: Language,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Budget {
#[serde(default)]
pub max_eur_per_task: f64,
#[serde(default)]
pub max_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Language {
#[serde(default = "default_chat")]
pub chat: String,
#[serde(default = "default_deliverables")]
pub deliverables: String,
}
fn default_chat() -> String {
"tr".into()
}
fn default_deliverables() -> String {
"en".into()
}
const REQUIRED_FIELDS: &[&str] = &["name", "tier", "department", "reports_to", "autonomy"];
/// Walks `agents/` and parses every `.md` into an `AgentSpec`.
///
/// Returns the specs + any validation errors. A spec with errors is excluded
/// from the success list and reported in the error vec.
pub fn validate_agents(root: &Path) -> Result<(Vec<AgentSpec>, Vec<SpecError>)> {
let mut specs = Vec::new();
let mut errors = Vec::new();
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let rel = path.strip_prefix(root).unwrap_or(path).display().to_string();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
errors.push(SpecError::Io(rel, e.to_string()));
continue;
}
};
let frontmatter = match extract_frontmatter(&content) {
Some(fm) => fm,
None => {
errors.push(SpecError::Parse(rel, "no --- frontmatter block found".into()));
continue;
}
};
// Parse the YAML frontmatter as JSON-ish: convert simple YAML to JSON.
// (Agent frontmatter is flat — key: value + list items — so a minimal
// conversion suffices without a full YAML parser dep.)
let json_str = match yaml_to_json(frontmatter) {
Ok(j) => j,
Err(e) => {
errors.push(SpecError::Parse(rel, e.to_string()));
continue;
}
};
let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => {
errors.push(SpecError::Parse(rel, e.to_string()));
continue;
}
};
// Check required fields.
let obj = match parsed.as_object() {
Some(o) => o,
None => {
errors.push(SpecError::Parse(rel, "frontmatter is not an object".into()));
continue;
}
};
let mut missing = Vec::new();
for field in REQUIRED_FIELDS {
if !obj.contains_key(*field) {
missing.push(*field);
}
}
if !missing.is_empty() {
errors.push(SpecError::MissingField(missing[0], rel));
continue;
}
// Deserialize into the typed struct (best-effort; ignore unknown fields).
let spec: AgentSpec = match serde_json::from_value(parsed) {
Ok(s) => s,
Err(e) => {
errors.push(SpecError::Parse(rel, e.to_string()));
continue;
}
};
specs.push(spec);
}
Ok((specs, errors))
}
/// Extracts the content between `---` fences at the top of a markdown file.
fn extract_frontmatter(content: &str) -> Option<&str> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first_fence = &trimmed[3..];
let end = after_first_fence.find("\n---")?;
Some(after_first_fence[..end].trim())
}
/// Minimal YAML→JSON converter for flat agent-spec frontmatter.
///
/// Supports only the subset we use: `key: value`, `key: [a, b, c]` inline
/// arrays, and block arrays (`- item`). Nested objects (like `budget:` with
/// sub-keys) and `language:` are handled with a simple indentation stack.
/// This avoids pulling a full YAML parser dep into the scaffold.
fn yaml_to_json(yaml: &str) -> Result<String> {
let mut out = String::from("{");
let lines: Vec<&str> = yaml.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim_end();
if line.trim().is_empty() || line.trim_start().starts_with('#') {
i += 1;
continue;
}
let (key, rest) = match line.split_once(':') {
Some((k, v)) => (k.trim(), v.trim()),
None => {
i += 1;
continue;
}
};
if rest.is_empty() {
// Could be a block array (`- item`) or a nested object.
// Peek ahead: collect comment + dash lines as array items.
let mut items: Vec<String> = Vec::new();
let mut j = i + 1;
while j < lines.len() {
let l = lines[j].trim();
if l.is_empty() {
j += 1;
continue;
}
// Skip comment lines inside a block array.
if l.starts_with('#') {
j += 1;
continue;
}
if !l.starts_with('-') {
break;
}
let val = l.trim_start_matches('-').trim();
items.push(quote_or_passthrough(val));
j += 1;
}
if !items.is_empty() {
out.push_str(&format!("\"{key}\":[{}],", items.join(",")));
i = j;
continue;
}
// Nested object: collect indented lines until dedent.
let mut sub = String::from("{");
while j < lines.len() {
let l = lines[j];
if !l.starts_with(' ') && !l.starts_with('\t') {
break;
}
if let Some((sk, sv)) = l.trim().split_once(':') {
sub.push_str(&format!(
"\"{}\":{},",
sk.trim(),
quote_or_passthrough(sv.trim())
));
}
j += 1;
}
sub.pop(); // trailing comma
sub.push('}');
out.push_str(&format!("\"{key}\":{sub},"));
i = j;
continue;
}
// Inline array? `[a, b, c]`
if rest.starts_with('[') {
let inner = rest
.trim_start_matches('[')
.trim_end_matches(']')
.split(',')
.map(|s| quote_or_passthrough(s.trim()))
.collect::<Vec<_>>()
.join(",");
out.push_str(&format!("\"{key}\":[{inner}],"));
} else {
out.push_str(&format!(
"\"{key}\":{},",
quote_or_passthrough(rest)
));
}
i += 1;
}
if out.ends_with(',') {
out.pop();
}
out.push('}');
Ok(out)
}
/// Quotes a value as a JSON string unless it looks like a number or bool.
fn quote_or_passthrough(v: &str) -> String {
let v = v.trim();
if v.is_empty() {
return "\"\"".into();
}
if v == "true" || v == "false" || v == "null" {
return v.into();
}
if v.parse::<f64>().is_ok() {
return v.into();
}
// Strip surrounding quotes if present, then re-add as JSON string.
let v = v.trim_matches('"');
format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\""))
}