Final phase of agent substrate v1. 5 shipped agents now declare role at manifest level; assembler expands role's capability text fragments into the generated .md at a new `# AGENT SUBSTRATE — role <name>` section. Non-migrated agents byte-identical (golden snapshots green). Migrated agents: - kei-code-implementer → edit-local (8 caps: no-git-ops + scope/* + quality/* + safety::no-dep-bump + report-format) - kei-critic → read-only (tools::read-only + output::report-format + output::severity-grade) - kei-architect → read-only - kei-security-auditor → read-only - kei-validator → read-only _assembler/ extensions: - manifest.rs: substrate_role: Option<String> - assembler.rs: write_substrate() before blocks (backward-compat; no role = no substrate section) - substrate.rs (new, 102 LOC): loads _roles/<name>.toml, iterates capabilities.required, reads _capabilities/<cat>/<slug>/text.md, joins with \n\n---\n\n separator - validator.rs: substrate role existence + cap-text presence check - tests/substrate_role.rs (4 tests): happy path, unknown role, missing capability text, byte-parity on non-migrated - tests/regenerate_migrated.rs (ignored by default): regeneration gate _templates/task-examples/ — 5 example task.toml per migrated agent showing orchestrator the valid invocation shape. docs/AGENT-SUBSTRATE-SCHEMA.md: Phase 5 row ticked ✓ + Migrated agents subsection listing 5 agents with roles + pointer to examples. tests/substrate_integration.sh: +8 Phase-5 assertions - All 5 migrated .md files contain "# AGENT SUBSTRATE — role" - kei-code-implementer.md contains "MUST NOT invoke git" (policy::no-git-ops) - Every _templates/task-examples/*.toml parses as valid TOML - cargo check --workspace still passes post-migration - kei-agent-runtime compose works on edit-local-forge.toml example Tests: assembler 40/40 (was 30, +4 substrate_role + +1 ignored regen), kei-agent-runtime + kei-capability 37/37 preserved. Deferred: remaining 7 non-core agents (cost-guardian, modal-runner, fal-ai-runner, infra/ml-implementer, ml-researcher, researcher) migrate in v0.24 wave. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
6.9 KiB
Rust
198 lines
6.9 KiB
Rust
//! Manifest validator. Enforces Constructor Pattern invariants.
|
|
//! Hard-fails on missing obligatory blocks, missing handoffs, unknown blocks.
|
|
//!
|
|
//! Detailed sub-checks live in their own cubes:
|
|
//! - `placeholders::check` — {{PLACEHOLDER}} substitution guard
|
|
//! - `schemas_export::load` — dynamic artifact-schema whitelist loader
|
|
//! - this file — structural checks + artifact-schema names
|
|
|
|
use crate::manifest::Manifest;
|
|
use crate::placeholders;
|
|
use crate::schemas_export;
|
|
use crate::substrate;
|
|
use std::collections::BTreeSet;
|
|
use std::path::Path;
|
|
|
|
pub const OBLIGATORY: &[&str] = &["baseline", "evidence-grading", "memory-protocol"];
|
|
|
|
/// Back-compat alias for external callers. The SSoT lives in
|
|
/// `schemas_export::BUILTIN`.
|
|
#[allow(dead_code)]
|
|
pub const KNOWN_ARTIFACT_SCHEMAS: &[&str] = schemas_export::BUILTIN;
|
|
|
|
pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
|
|
for required in OBLIGATORY {
|
|
if !m.blocks.iter().any(|b| b == required) {
|
|
return Err(format!("missing obligatory block: {required}"));
|
|
}
|
|
}
|
|
|
|
if m.handoff.is_empty() {
|
|
return Err("at least one handoff required".into());
|
|
}
|
|
|
|
for block in &m.blocks {
|
|
let path = blocks_dir.join(format!("{block}.md"));
|
|
if !path.exists() {
|
|
return Err(format!("block '{block}' not found at {}", path.display()));
|
|
}
|
|
}
|
|
|
|
if m.domain_in.is_empty() {
|
|
return Err("domain_in must have at least one entry".into());
|
|
}
|
|
if m.forbidden_domain.is_empty() {
|
|
return Err("forbidden_domain must have at least one entry".into());
|
|
}
|
|
if m.role.trim().is_empty() {
|
|
return Err("role must not be empty".into());
|
|
}
|
|
|
|
placeholders::check(m)?;
|
|
let known = schemas_export::load(blocks_dir);
|
|
check_artifact_schemas(m, &known)?;
|
|
check_substrate_role(m, blocks_dir)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// If a manifest declares `substrate_role`, verify the role file exists
|
|
/// and every capability it references has a `text.md`. Keeping the check
|
|
/// here (not only at assemble time) turns mistakes into up-front failures.
|
|
fn check_substrate_role(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
|
|
let Some(role) = &m.substrate_role else { return Ok(()); };
|
|
let root = blocks_dir
|
|
.parent()
|
|
.ok_or_else(|| "blocks_dir has no parent (can't locate _roles/)".to_string())?;
|
|
let caps = substrate::load_role_capabilities(root, role)?;
|
|
for cap in &caps {
|
|
substrate::load_capability_text(root, cap)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// v0.15: if a manifest references artifact schema names, they must be in the
|
|
/// known whitelist. Missing fields are allowed (non-breaking extension).
|
|
fn check_artifact_schemas(m: &Manifest, known: &BTreeSet<String>) -> Result<(), String> {
|
|
if let Some(name) = &m.produces_artifact {
|
|
check_known(name, "produces_artifact", known)?;
|
|
}
|
|
for (i, h) in m.handoff.iter().enumerate() {
|
|
if let Some(name) = &h.expects_artifact {
|
|
check_known(name, &format!("handoff[{i}].expects_artifact"), known)?;
|
|
}
|
|
if let Some(name) = &h.produces_artifact {
|
|
check_known(name, &format!("handoff[{i}].produces_artifact"), known)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_known(name: &str, field: &str, known: &BTreeSet<String>) -> Result<(), String> {
|
|
if known.contains(name) {
|
|
return Ok(());
|
|
}
|
|
let list: Vec<&str> = known.iter().map(String::as_str).collect();
|
|
Err(format!(
|
|
"unknown artifact schema '{name}' in field '{field}' — must be one of {list:?}"
|
|
))
|
|
}
|
|
|
|
#[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(),
|
|
expects_artifact: None,
|
|
produces_artifact: None,
|
|
}],
|
|
output_extra_fields: vec![],
|
|
memory_project: None,
|
|
project_claudemd: None,
|
|
references: None,
|
|
produces_artifact: None,
|
|
substrate_role: None,
|
|
}
|
|
}
|
|
|
|
fn builtin_set() -> BTreeSet<String> {
|
|
schemas_export::BUILTIN.iter().map(|s| (*s).to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_schemas_absent_passes() {
|
|
let m = base();
|
|
assert!(check_artifact_schemas(&m, &builtin_set()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_schemas_known_names_pass() {
|
|
let mut m = base();
|
|
m.produces_artifact = Some("spec".into());
|
|
m.handoff[0].expects_artifact = Some("plan".into());
|
|
m.handoff[0].produces_artifact = Some("patch".into());
|
|
assert!(check_artifact_schemas(&m, &builtin_set()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_schemas_reject_unknown_produces() {
|
|
let mut m = base();
|
|
m.produces_artifact = Some("not-a-schema".into());
|
|
let err = check_artifact_schemas(&m, &builtin_set()).unwrap_err();
|
|
assert!(err.contains("not-a-schema"), "err: {err}");
|
|
assert!(err.contains("produces_artifact"), "err: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_schemas_reject_unknown_expects_in_handoff() {
|
|
let mut m = base();
|
|
m.handoff[0].expects_artifact = Some("zzz".into());
|
|
let err = check_artifact_schemas(&m, &builtin_set()).unwrap_err();
|
|
assert!(err.contains("zzz"), "err: {err}");
|
|
assert!(err.contains("handoff[0].expects_artifact"), "err: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_schemas_do_not_drift_from_kei_artifact() {
|
|
// Structural drift test (no runtime dep on kei-artifact): read the
|
|
// primitive's source and confirm its BUILTIN list matches ours.
|
|
let primitive = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("..")
|
|
.join("_primitives/_rust/kei-artifact/src/schemas.rs");
|
|
if !primitive.exists() {
|
|
eprintln!("skip drift test: primitive not at {}", primitive.display());
|
|
return;
|
|
}
|
|
let src = std::fs::read_to_string(&primitive).unwrap();
|
|
let mut names: Vec<String> = Vec::new();
|
|
for line in src.lines() {
|
|
let t = line.trim();
|
|
if let Some(rest) = t.strip_prefix("(\"") {
|
|
if let Some(end) = rest.find("\",") {
|
|
names.push(rest[..end].to_string());
|
|
}
|
|
}
|
|
}
|
|
let mine: Vec<String> = schemas_export::BUILTIN
|
|
.iter()
|
|
.map(|s| (*s).to_string())
|
|
.collect();
|
|
assert_eq!(
|
|
names, mine,
|
|
"kei-artifact BUILTIN and schemas_export::BUILTIN drifted"
|
|
);
|
|
}
|
|
}
|