feat(primitives): kei-artifact typed handoff pipeline (BMAD-style doc passthrough)
- kei-artifact Rust crate (25th): schema registry + artifact store + SHA-256 id + chain walker - 5 schemas (JSON Schema 2020-12 strict): spec / plan / patch / review / research - Manifest extension: optional produces_artifact + expects_artifact per handoff (non-breaking) - Validator extension: KNOWN_ARTIFACT_SCHEMAS whitelist check + 4 new tests - 3 kei-* manifests updated with typed handoff (architect→code-implementer→critic chain) - compose-solution phase-5 cross-ref to kei-artifact Tests: 189 Rust workspace (was 167, +22 artifact tests) + 24 assembler (was 20, +4 validator tests)
This commit is contained in:
parent
4b0185a3d1
commit
537589e6a7
27 changed files with 1517 additions and 69 deletions
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
mod assembler;
|
||||
mod manifest;
|
||||
mod placeholders;
|
||||
mod validator;
|
||||
|
||||
use manifest::Manifest;
|
||||
|
|
|
|||
|
|
@ -19,12 +19,22 @@ pub struct Manifest {
|
|||
pub memory_project: Option<String>,
|
||||
pub project_claudemd: Option<String>,
|
||||
pub references: Option<References>,
|
||||
/// v0.15: optional typed-artifact schema this agent emits on completion.
|
||||
/// Must be one of the names in `artifact_schemas::KNOWN`.
|
||||
#[serde(default)]
|
||||
pub produces_artifact: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Handoff {
|
||||
pub target: String,
|
||||
pub trigger: String,
|
||||
/// v0.15: optional schema name the target consumes from this handoff.
|
||||
#[serde(default)]
|
||||
pub expects_artifact: Option<String>,
|
||||
/// v0.15: optional schema name this agent produces for the target.
|
||||
#[serde(default)]
|
||||
pub produces_artifact: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
|||
124
_assembler/src/placeholders.rs
Normal file
124
_assembler/src/placeholders.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! Placeholder check — reject unsubstituted `{{PLACEHOLDER}}` tokens.
|
||||
//!
|
||||
//! Constructor Pattern: one cube = one validation concern.
|
||||
//! Extracted from `validator.rs` to keep that file under 200 LOC.
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
/// Reject manifests that still carry `{{PLACEHOLDER}}` tokens — the wizard
|
||||
/// should have substituted them. Matches `{{...}}` conservatively (not
|
||||
/// single braces).
|
||||
pub fn check(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 {
|
||||
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(),
|
||||
expects_artifact: None,
|
||||
produces_artifact: None,
|
||||
}],
|
||||
output_extra_fields: vec![],
|
||||
memory_project: None,
|
||||
project_claudemd: None,
|
||||
references: None,
|
||||
produces_artifact: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_placeholder_in_memory_project() {
|
||||
let mut m = base();
|
||||
m.memory_project = Some("{{MEMORY_PROJECT}}".into());
|
||||
let err = check(&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(&m).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_empty_manifest() {
|
||||
assert!(check(&base()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_placeholder_in_role() {
|
||||
let mut m = base();
|
||||
m.role = "do {{THING}}".into();
|
||||
assert!(check(&m).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,21 @@
|
|||
//! 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
|
||||
//! - this file — structural checks + artifact-schema names
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
use crate::placeholders;
|
||||
use std::path::Path;
|
||||
|
||||
pub const OBLIGATORY: &[&str] = &["baseline", "evidence-grading", "memory-protocol"];
|
||||
|
||||
/// v0.15: canonical artifact schema names shipped by `kei-artifact`.
|
||||
/// Mirror of `kei_artifact::schema::KNOWN_SCHEMAS` — kept here as a string
|
||||
/// literal to avoid coupling the assembler crate to the runtime primitive.
|
||||
pub const KNOWN_ARTIFACT_SCHEMAS: &[&str] = &["spec", "plan", "patch", "review", "research"];
|
||||
|
||||
pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
|
||||
for required in OBLIGATORY {
|
||||
if !m.blocks.iter().any(|b| b == required) {
|
||||
|
|
@ -34,70 +44,38 @@ pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
|
|||
return Err("role must not be empty".into());
|
||||
}
|
||||
|
||||
check_no_placeholders(m)?;
|
||||
placeholders::check(m)?;
|
||||
check_artifact_schemas(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)?;
|
||||
/// v0.15: if a manifest references artifact schema names, they must be in the
|
||||
/// known whitelist shipped with `kei-artifact`. Missing fields are allowed
|
||||
/// (non-breaking extension).
|
||||
fn check_artifact_schemas(m: &Manifest) -> Result<(), String> {
|
||||
if let Some(name) = &m.produces_artifact {
|
||||
check_known(name, "produces_artifact")?;
|
||||
}
|
||||
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)?;
|
||||
if let Some(name) = &h.expects_artifact {
|
||||
check_known(name, &format!("handoff[{i}].expects_artifact"))?;
|
||||
}
|
||||
if let Some(name) = &h.produces_artifact {
|
||||
check_known(name, &format!("handoff[{i}].produces_artifact"))?;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
fn check_known(name: &str, field: &str) -> Result<(), String> {
|
||||
if KNOWN_ARTIFACT_SCHEMAS.iter().any(|s| *s == name) {
|
||||
return Ok(());
|
||||
}
|
||||
false
|
||||
Err(format!(
|
||||
"unknown artifact schema '{name}' in field '{field}' — must be one of {:?}",
|
||||
KNOWN_ARTIFACT_SCHEMAS
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -115,39 +93,50 @@ mod tests {
|
|||
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() }],
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
fn artifact_schemas_absent_passes() {
|
||||
let m = base();
|
||||
assert!(check_artifact_schemas(&m).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_single_braces() {
|
||||
fn artifact_schemas_known_names_pass() {
|
||||
let mut m = base();
|
||||
m.description = "hello {world}".into();
|
||||
assert!(check_no_placeholders(&m).is_ok());
|
||||
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).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_empty_manifest() {
|
||||
assert!(check_no_placeholders(&base()).is_ok());
|
||||
fn artifact_schemas_reject_unknown_produces() {
|
||||
let mut m = base();
|
||||
m.produces_artifact = Some("not-a-schema".into());
|
||||
let err = check_artifact_schemas(&m).unwrap_err();
|
||||
assert!(err.contains("not-a-schema"), "err: {err}");
|
||||
assert!(err.contains("produces_artifact"), "err: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_placeholder_in_role() {
|
||||
fn artifact_schemas_reject_unknown_expects_in_handoff() {
|
||||
let mut m = base();
|
||||
m.role = "do {{THING}}".into();
|
||||
assert!(check_no_placeholders(&m).is_err());
|
||||
m.handoff[0].expects_artifact = Some("zzz".into());
|
||||
let err = check_artifact_schemas(&m).unwrap_err();
|
||||
assert!(err.contains("zzz"), "err: {err}");
|
||||
assert!(err.contains("handoff[0].expects_artifact"), "err: {err}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,10 +64,15 @@ output_extra_fields = [
|
|||
"Decisive verdict: <ONE recommended approach with justification — no \"it depends\">",
|
||||
]
|
||||
|
||||
# v0.15: typed-artifact handoff — kei-architect emits a `spec` artifact
|
||||
# consumable by downstream agents via kei-artifact store. Optional, non-breaking.
|
||||
produces_artifact = "spec"
|
||||
|
||||
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
|
||||
[[handoff]]
|
||||
target = "kei-code-implementer"
|
||||
trigger = "structural finding implies a concrete refactor / extraction / module split"
|
||||
produces_artifact = "spec"
|
||||
|
||||
[[handoff]]
|
||||
target = "kei-critic"
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ output_extra_fields = [
|
|||
"Checkpoints: <commit-sha or stash> — <description>",
|
||||
]
|
||||
|
||||
# v0.15: typed-artifact handoff — implementer consumes `spec` from
|
||||
# kei-architect and emits a `patch` manifest for downstream review.
|
||||
produces_artifact = "patch"
|
||||
|
||||
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
|
||||
[[handoff]]
|
||||
target = "kei-ml-implementer"
|
||||
|
|
@ -75,6 +79,7 @@ trigger = "task involves deploy / CI/CD / secrets / IaC / credentials / public-s
|
|||
[[handoff]]
|
||||
target = "kei-critic"
|
||||
trigger = "anti-pattern sweep / code smell review on large diff (>500 LOC) or long function chains"
|
||||
produces_artifact = "patch"
|
||||
|
||||
[[handoff]]
|
||||
target = "kei-security-auditor"
|
||||
|
|
|
|||
|
|
@ -51,10 +51,16 @@ output_extra_fields = [
|
|||
"Categories covered: security | bugs | anti-patterns | performance | tech-debt",
|
||||
]
|
||||
|
||||
# v0.15: typed-artifact handoff — critic consumes `patch` from code-implementer
|
||||
# and emits a `review` artifact with severity-sorted findings.
|
||||
produces_artifact = "review"
|
||||
|
||||
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
|
||||
[[handoff]]
|
||||
target = "kei-code-implementer"
|
||||
trigger = "confirmed findings need code edits (user approves fix plan first)"
|
||||
expects_artifact = "patch"
|
||||
produces_artifact = "review"
|
||||
|
||||
[[handoff]]
|
||||
target = "kei-security-auditor"
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ minimal = []
|
|||
core = ["tomd", "genesis-scan"]
|
||||
frontend = ["mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode"]
|
||||
ops = ["kei-ledger", "ssh-check", "firewall-diff", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship"]
|
||||
dev = ["kei-migrate", "kei-changelog", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store"]
|
||||
dev = ["kei-migrate", "kei-changelog", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-artifact"]
|
||||
mcp = ["kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth"]
|
||||
full = ["tomd", "genesis-scan", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth"]
|
||||
full = ["tomd", "genesis-scan", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth", "kei-artifact"]
|
||||
|
||||
# --- shell primitives (13) -------------------------------------------------
|
||||
|
||||
|
|
@ -251,3 +251,11 @@ kind = "rust"
|
|||
crate = "kei-auth"
|
||||
deps = ["rusqlite bundled", "hmac", "sha2"]
|
||||
desc = "Multi-tenant session tokens with scopes + HMAC-signed expiry (rewrite, not port)"
|
||||
|
||||
# --- v0.15 artifact handoff pipeline (1) -----------------------------------
|
||||
|
||||
[primitive.kei-artifact]
|
||||
kind = "rust"
|
||||
crate = "kei-artifact"
|
||||
deps = ["rusqlite bundled"]
|
||||
desc = "Typed artifact handoff pipeline — schema-validated content pass-between agents (BMAD-style)"
|
||||
|
|
|
|||
14
_primitives/_rust/Cargo.lock
generated
14
_primitives/_rust/Cargo.lock
generated
|
|
@ -909,6 +909,20 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-artifact"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-auth"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ members = [
|
|||
"kei-social-store",
|
||||
"kei-curator",
|
||||
"kei-auth",
|
||||
# v0.15 artifact handoff pipeline
|
||||
"kei-artifact",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
26
_primitives/_rust/kei-artifact/Cargo.toml
Normal file
26
_primitives/_rust/kei-artifact/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "kei-artifact"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Typed artifact handoff pipeline — BMAD-style document pass-between agents with JSON Schema validation"
|
||||
|
||||
[[bin]]
|
||||
name = "kei-artifact"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "kei_artifact"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
48
_primitives/_rust/kei-artifact/schemas/patch.json
Normal file
48
_primitives/_rust/kei-artifact/schemas/patch.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "kei-artifact://patch",
|
||||
"title": "File-Changes Manifest",
|
||||
"description": "Implementer output — manifest of changed files with ops.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["summary", "changes"],
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"parent_plan_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"changes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "op", "summary"],
|
||||
"properties": {
|
||||
"path": {"type": "string", "minLength": 1},
|
||||
"op": {"type": "string", "enum": ["add", "mod", "del"]},
|
||||
"summary": {"type": "string", "minLength": 1},
|
||||
"loc_delta": {"type": "integer"},
|
||||
"diff": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "status"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"status": {"type": "string", "enum": ["pass", "fail", "skip"]},
|
||||
"reproduce": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
_primitives/_rust/kei-artifact/schemas/plan.json
Normal file
37
_primitives/_rust/kei-artifact/schemas/plan.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "kei-artifact://plan",
|
||||
"title": "Karpathy Goal-Driven Plan",
|
||||
"description": "Ordered plan steps, each with a verify-criterion.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["goal", "steps"],
|
||||
"properties": {
|
||||
"goal": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"parent_spec_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["step", "verify"],
|
||||
"properties": {
|
||||
"step": {"type": "string", "minLength": 1},
|
||||
"verify": {"type": "string", "minLength": 1},
|
||||
"est_minutes": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
}
|
||||
},
|
||||
"risks": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
_primitives/_rust/kei-artifact/schemas/research.json
Normal file
46
_primitives/_rust/kei-artifact/schemas/research.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "kei-artifact://research",
|
||||
"title": "Research Claims",
|
||||
"description": "Researcher output — claims + evidence grades + sources.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["question", "claims"],
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"claims": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["claim", "evidence_grade"],
|
||||
"properties": {
|
||||
"claim": {"type": "string", "minLength": 1},
|
||||
"evidence_grade": {"type": "string", "enum": ["E1", "E2", "E3", "E4", "E5", "E6"]},
|
||||
"confidence": {"type": "string", "enum": ["100", "80", "50", "30", "0"]},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": {"type": "string", "minLength": 1},
|
||||
"title": {"type": "string"},
|
||||
"verified": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gaps": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
_primitives/_rust/kei-artifact/schemas/review.json
Normal file
41
_primitives/_rust/kei-artifact/schemas/review.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "kei-artifact://review",
|
||||
"title": "Review Findings",
|
||||
"description": "Critic / security / validator output — severity-sorted findings.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["reviewer", "findings"],
|
||||
"properties": {
|
||||
"reviewer": {
|
||||
"type": "string",
|
||||
"enum": ["kei-critic", "kei-security-auditor", "kei-validator", "kei-architect"]
|
||||
},
|
||||
"parent_patch_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["severity", "title", "file", "line"],
|
||||
"properties": {
|
||||
"severity": {"type": "string", "enum": ["critical", "high", "medium", "low", "info"]},
|
||||
"category": {"type": "string", "enum": ["security", "bug", "anti-pattern", "performance", "tech-debt", "style"]},
|
||||
"title": {"type": "string", "minLength": 1},
|
||||
"file": {"type": "string", "minLength": 1},
|
||||
"line": {"type": "integer", "minimum": 0},
|
||||
"problem": {"type": "string"},
|
||||
"impact": {"type": "string"},
|
||||
"fix": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"enum": ["approve", "request_changes", "reject"]
|
||||
}
|
||||
}
|
||||
}
|
||||
40
_primitives/_rust/kei-artifact/schemas/spec.json
Normal file
40
_primitives/_rust/kei-artifact/schemas/spec.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "kei-artifact://spec",
|
||||
"title": "Agent Specification",
|
||||
"description": "Architect output — what to build and under which constraints.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["goal", "constraints", "invariants"],
|
||||
"properties": {
|
||||
"goal": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"constraints": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"invariants": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"tradeoffs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["choice", "rejected", "because"],
|
||||
"properties": {
|
||||
"choice": {"type": "string", "minLength": 1},
|
||||
"rejected": {"type": "string", "minLength": 1},
|
||||
"because": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
},
|
||||
"evidence_grade": {
|
||||
"type": "string",
|
||||
"enum": ["E1", "E2", "E3", "E4", "E5", "E6"]
|
||||
}
|
||||
}
|
||||
}
|
||||
191
_primitives/_rust/kei-artifact/src/artifact.rs
Normal file
191
_primitives/_rust/kei-artifact/src/artifact.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//! Artifact CRUD — register_schema / emit / get / list / chain / validate.
|
||||
//!
|
||||
//! Constructor Pattern: one concern per public fn, each < 30 LOC.
|
||||
//! Every write path uses `artifact_id` for idempotency.
|
||||
|
||||
use crate::hash::artifact_id;
|
||||
use crate::store::Store;
|
||||
use crate::validate::validate_content;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Artifact {
|
||||
pub id: String,
|
||||
pub schema_name: String,
|
||||
pub source_agent: String,
|
||||
pub content: Vec<u8>,
|
||||
pub meta_json: Option<String>,
|
||||
pub parent_artifact_id: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ArtifactFilter {
|
||||
pub schema_name: Option<String>,
|
||||
pub source_agent: Option<String>,
|
||||
pub since: Option<i64>,
|
||||
}
|
||||
|
||||
/// Insert a schema under `name`. Overwrite if present (idempotent registry).
|
||||
pub fn register_schema(store: &Store, name: &str, json_schema: &str) -> Result<()> {
|
||||
let parsed: Value = serde_json::from_str(json_schema).context("schema is not valid JSON")?;
|
||||
if !parsed.is_object() {
|
||||
return Err(anyhow!("schema must be a JSON object"));
|
||||
}
|
||||
let now = Utc::now().timestamp();
|
||||
store.conn().execute(
|
||||
"INSERT INTO schemas (name, json_schema, registered_at) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(name) DO UPDATE SET json_schema=excluded.json_schema,
|
||||
registered_at=excluded.registered_at",
|
||||
params![name, json_schema, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_schemas(store: &Store) -> Result<Vec<String>> {
|
||||
let mut stmt = store.conn().prepare("SELECT name FROM schemas ORDER BY name")?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn load_schema(store: &Store, name: &str) -> Result<Value> {
|
||||
let raw: String = store
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT json_schema FROM schemas WHERE name = ?1",
|
||||
params![name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("unknown schema '{name}' — register it first"))?;
|
||||
serde_json::from_str(&raw).context("stored schema is not valid JSON")
|
||||
}
|
||||
|
||||
/// Emit a typed artifact. Returns the id. Idempotent (same bytes → same id).
|
||||
pub fn emit(
|
||||
store: &Store,
|
||||
schema_name: &str,
|
||||
source_agent: &str,
|
||||
content: &[u8],
|
||||
meta_json: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
) -> Result<String> {
|
||||
if let Some(pid) = parent {
|
||||
if !has_artifact(store, pid)? {
|
||||
return Err(anyhow!("parent artifact '{pid}' not found"));
|
||||
}
|
||||
}
|
||||
let schema = load_schema(store, schema_name)?;
|
||||
let value: Value = serde_json::from_slice(content).context("artifact content not JSON")?;
|
||||
validate_content(&schema, &value).map_err(|e| anyhow!("schema-validation: {e}"))?;
|
||||
let id = artifact_id(schema_name, content);
|
||||
let now = Utc::now().timestamp();
|
||||
store.conn().execute(
|
||||
"INSERT OR IGNORE INTO artifacts
|
||||
(id, schema_name, source_agent, content, meta_json, parent_artifact_id, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, schema_name, source_agent, content, meta_json, parent, now],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn has_artifact(store: &Store, id: &str) -> Result<bool> {
|
||||
let n: i64 = store.conn().query_row(
|
||||
"SELECT COUNT(*) FROM artifacts WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub fn get(store: &Store, id: &str) -> Result<Option<Artifact>> {
|
||||
let row = store
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT id, schema_name, source_agent, content, meta_json,
|
||||
parent_artifact_id, created_at
|
||||
FROM artifacts WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_artifact,
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn row_to_artifact(r: &rusqlite::Row) -> rusqlite::Result<Artifact> {
|
||||
Ok(Artifact {
|
||||
id: r.get(0)?,
|
||||
schema_name: r.get(1)?,
|
||||
source_agent: r.get(2)?,
|
||||
content: r.get(3)?,
|
||||
meta_json: r.get(4)?,
|
||||
parent_artifact_id: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-validate a stored artifact against its schema. Useful after schema
|
||||
/// revision to detect rows that no longer satisfy the contract.
|
||||
pub fn validate_by_id(store: &Store, id: &str) -> Result<()> {
|
||||
let a = get(store, id)?.ok_or_else(|| anyhow!("artifact '{id}' not found"))?;
|
||||
let schema = load_schema(store, &a.schema_name)?;
|
||||
let value: Value = serde_json::from_slice(&a.content).context("artifact content not JSON")?;
|
||||
validate_content(&schema, &value).map_err(|e| anyhow!("schema-validation: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filter-based listing; ORDER BY created_at DESC.
|
||||
pub fn list(store: &Store, filter: &ArtifactFilter) -> Result<Vec<Artifact>> {
|
||||
let (sql, args) = build_list_sql(filter);
|
||||
let mut stmt = store.conn().prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(args.iter()), row_to_artifact)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn build_list_sql(f: &ArtifactFilter) -> (String, Vec<String>) {
|
||||
let mut sql = String::from(
|
||||
"SELECT id, schema_name, source_agent, content, meta_json, \
|
||||
parent_artifact_id, created_at FROM artifacts",
|
||||
);
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
let mut clauses: Vec<String> = Vec::new();
|
||||
if let Some(s) = &f.schema_name {
|
||||
clauses.push(format!("schema_name = ?{}", clauses.len() + 1));
|
||||
args.push(s.clone());
|
||||
}
|
||||
if let Some(a) = &f.source_agent {
|
||||
clauses.push(format!("source_agent = ?{}", clauses.len() + 1));
|
||||
args.push(a.clone());
|
||||
}
|
||||
if let Some(since) = f.since {
|
||||
clauses.push(format!("created_at >= ?{}", clauses.len() + 1));
|
||||
args.push(since.to_string());
|
||||
}
|
||||
if !clauses.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&clauses.join(" AND "));
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
(sql, args)
|
||||
}
|
||||
|
||||
/// Walk the parent chain upward from `id`. Root first, youngest last.
|
||||
pub fn chain(store: &Store, id: &str) -> Result<Vec<Artifact>> {
|
||||
let mut out: Vec<Artifact> = Vec::new();
|
||||
let mut current = Some(id.to_string());
|
||||
while let Some(cid) = current {
|
||||
let a = get(store, &cid)?.ok_or_else(|| anyhow!("artifact '{cid}' not found"))?;
|
||||
current = a.parent_artifact_id.clone();
|
||||
out.push(a);
|
||||
}
|
||||
out.reverse();
|
||||
Ok(out)
|
||||
}
|
||||
54
_primitives/_rust/kei-artifact/src/hash.rs
Normal file
54
_primitives/_rust/kei-artifact/src/hash.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! sha256-based artifact id.
|
||||
//!
|
||||
//! Id = sha256(schema_name || 0x00 || content_bytes). Including the schema
|
||||
//! name prevents trivial collisions across different content types with the
|
||||
//! same payload bytes. Hex-encoded 64-char string.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Deterministic artifact id from schema name + content bytes.
|
||||
pub fn artifact_id(schema_name: &str, content: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(schema_name.as_bytes());
|
||||
h.update([0u8]);
|
||||
h.update(content);
|
||||
let out = h.finalize();
|
||||
hex_encode(&out)
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
const TABLE: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(TABLE[(*b >> 4) as usize] as char);
|
||||
s.push(TABLE[(*b & 0x0f) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn id_is_deterministic_for_same_input() {
|
||||
let a = artifact_id("spec", b"hello");
|
||||
let b = artifact_id("spec", b"hello");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_changes_with_schema_name() {
|
||||
let a = artifact_id("spec", b"hello");
|
||||
let b = artifact_id("plan", b"hello");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_changes_with_content() {
|
||||
let a = artifact_id("spec", b"hello");
|
||||
let b = artifact_id("spec", b"world");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
24
_primitives/_rust/kei-artifact/src/lib.rs
Normal file
24
_primitives/_rust/kei-artifact/src/lib.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//! kei-artifact — typed artifact handoff store.
|
||||
//!
|
||||
//! Constructor Pattern: one concern per file.
|
||||
//! - `schema` — SQL DDL + schema registry table.
|
||||
//! - `store` — `Store` cube (Connection wrapper).
|
||||
//! - `hash` — sha256 artifact id helper.
|
||||
//! - `schemas` — built-in schema registration (spec/plan/patch/review/research).
|
||||
//! - `validate` — minimal JSON Schema (strict subset of draft 2020-12).
|
||||
//! - `artifact` — CRUD on `artifacts` table (emit / get / list / chain).
|
||||
//!
|
||||
//! Storage path (CLI default): `~/.claude/artifacts/artifacts.sqlite` or
|
||||
//! `$KEI_ARTIFACT_DB`.
|
||||
|
||||
pub mod artifact;
|
||||
pub mod hash;
|
||||
pub mod schema;
|
||||
pub mod schemas;
|
||||
pub mod store;
|
||||
pub mod validate;
|
||||
|
||||
pub use artifact::{Artifact, ArtifactFilter};
|
||||
pub use hash::artifact_id;
|
||||
pub use store::Store;
|
||||
pub use validate::validate_content;
|
||||
198
_primitives/_rust/kei-artifact/src/main.rs
Normal file
198
_primitives/_rust/kei-artifact/src/main.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! kei-artifact CLI — register-schema / emit / get / list / validate / chain.
|
||||
//!
|
||||
//! Constructor Pattern: main.rs = dispatch only. Each `cmd_*` fn < 30 LOC.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use kei_artifact::artifact::{chain, emit, get, list, register_schema, validate_by_id, ArtifactFilter};
|
||||
use kei_artifact::schemas::register_builtins;
|
||||
use kei_artifact::Store;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "kei-artifact", version, about = "Typed artifact handoff store")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Initialise the db and register the 5 built-in schemas.
|
||||
Init,
|
||||
/// Register a JSON Schema file under a name.
|
||||
RegisterSchema {
|
||||
#[arg(long)] name: String,
|
||||
#[arg(long)] path: PathBuf,
|
||||
},
|
||||
/// Emit an artifact. Content file must be JSON.
|
||||
Emit {
|
||||
#[arg(long)] schema: String,
|
||||
#[arg(long)] from: String,
|
||||
#[arg(long)] content: PathBuf,
|
||||
#[arg(long)] meta: Vec<String>,
|
||||
#[arg(long)] parent: Option<String>,
|
||||
},
|
||||
/// Fetch an artifact by id.
|
||||
Get {
|
||||
id: String,
|
||||
#[arg(long, default_value = "typed")] format: String,
|
||||
},
|
||||
/// List artifacts; filter by schema / source / since-seconds.
|
||||
List {
|
||||
#[arg(long)] schema: Option<String>,
|
||||
#[arg(long)] from: Option<String>,
|
||||
#[arg(long)] since: Option<String>,
|
||||
},
|
||||
/// Re-validate a stored artifact against its schema.
|
||||
Validate { id: String },
|
||||
/// Walk the parent-handoff chain.
|
||||
Chain { id: String },
|
||||
}
|
||||
|
||||
fn db_path(o: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = o {
|
||||
return p;
|
||||
}
|
||||
if let Ok(e) = std::env::var("KEI_ARTIFACT_DB") {
|
||||
return PathBuf::from(e);
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".claude/artifacts/artifacts.sqlite")
|
||||
}
|
||||
|
||||
fn run() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let store = Store::open(&db_path(cli.db))?;
|
||||
dispatch(&store, cli.cmd)
|
||||
}
|
||||
|
||||
fn dispatch(store: &Store, cmd: Cmd) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
Cmd::Init => cmd_init(store),
|
||||
Cmd::RegisterSchema { name, path } => cmd_register(store, &name, &path),
|
||||
Cmd::Emit { schema, from, content, meta, parent } => {
|
||||
cmd_emit(store, &schema, &from, &content, &meta, parent.as_deref())
|
||||
}
|
||||
Cmd::Get { id, format } => cmd_get(store, &id, &format),
|
||||
Cmd::List { schema, from, since } => {
|
||||
cmd_list(store, schema.as_deref(), from.as_deref(), since.as_deref())
|
||||
}
|
||||
Cmd::Validate { id } => validate_by_id(store, &id),
|
||||
Cmd::Chain { id } => cmd_chain(store, &id),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_init(store: &Store) -> anyhow::Result<()> {
|
||||
register_builtins(store)?;
|
||||
println!("registered 5 built-in schemas: spec, plan, patch, review, research");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_register(store: &Store, name: &str, path: &std::path::Path) -> anyhow::Result<()> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
register_schema(store, name, &text)?;
|
||||
println!("registered schema '{name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_emit(
|
||||
store: &Store,
|
||||
schema: &str,
|
||||
from: &str,
|
||||
content_path: &std::path::Path,
|
||||
meta: &[String],
|
||||
parent: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let bytes = std::fs::read(content_path)?;
|
||||
let meta_json = if meta.is_empty() { None } else { Some(encode_meta(meta)?) };
|
||||
let id = emit(store, schema, from, &bytes, meta_json.as_deref(), parent)?;
|
||||
println!("{id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_meta(kvs: &[String]) -> anyhow::Result<String> {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for kv in kvs {
|
||||
let (k, v) = kv
|
||||
.split_once('=')
|
||||
.ok_or_else(|| anyhow::anyhow!("--meta expects key=value: {kv}"))?;
|
||||
obj.insert(k.to_string(), serde_json::Value::String(v.to_string()));
|
||||
}
|
||||
Ok(serde_json::Value::Object(obj).to_string())
|
||||
}
|
||||
|
||||
fn cmd_get(store: &Store, id: &str, format: &str) -> anyhow::Result<()> {
|
||||
let a = get(store, id)?.ok_or_else(|| anyhow::anyhow!("artifact not found: {id}"))?;
|
||||
match format {
|
||||
"json" => print_json(&a)?,
|
||||
"md" | "typed" => print_typed(&a)?,
|
||||
other => return Err(anyhow::anyhow!("unknown format '{other}'")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_json(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(a)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_typed(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
|
||||
let text = std::str::from_utf8(&a.content).unwrap_or("<binary>");
|
||||
println!("# artifact {} (schema={}, from={})", a.id, a.schema_name, a.source_agent);
|
||||
println!("{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_list(
|
||||
store: &Store,
|
||||
schema: Option<&str>,
|
||||
from: Option<&str>,
|
||||
since: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let filter = ArtifactFilter {
|
||||
schema_name: schema.map(str::to_string),
|
||||
source_agent: from.map(str::to_string),
|
||||
since: since.and_then(parse_since),
|
||||
};
|
||||
for a in list(store, &filter)? {
|
||||
println!("{}\t{}\t{}\t{}", a.id, a.schema_name, a.source_agent, a.created_at);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_since(s: &str) -> Option<i64> {
|
||||
// Accept raw epoch ints, or "1d" / "2h" / "30m" shorthands.
|
||||
if let Ok(n) = s.parse::<i64>() {
|
||||
return Some(n);
|
||||
}
|
||||
let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
|
||||
let n: i64 = num.parse().ok()?;
|
||||
let secs = match unit {
|
||||
"s" => n,
|
||||
"m" => n * 60,
|
||||
"h" => n * 3600,
|
||||
"d" => n * 86400,
|
||||
_ => return None,
|
||||
};
|
||||
Some(chrono::Utc::now().timestamp() - secs)
|
||||
}
|
||||
|
||||
fn cmd_chain(store: &Store, id: &str) -> anyhow::Result<()> {
|
||||
for a in chain(store, id)? {
|
||||
println!("{}\t{}\t{}", a.id, a.schema_name, a.source_agent);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("kei-artifact: {e:#}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
50
_primitives/_rust/kei-artifact/src/schema.rs
Normal file
50
_primitives/_rust/kei-artifact/src/schema.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! SQL schema DDL + migrations for the artifact store.
|
||||
//!
|
||||
//! Two tables:
|
||||
//! - `schemas` — registered JSON Schemas by name (SSoT for validation).
|
||||
//! - `artifacts` — typed content + metadata + parent pointer for handoff chain.
|
||||
|
||||
use rusqlite::{Connection, Result};
|
||||
|
||||
/// Ordered migrations. Index = schema version. Append only; never reorder.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
// v1 — initial schema
|
||||
"CREATE TABLE IF NOT EXISTS schemas (
|
||||
name TEXT PRIMARY KEY,
|
||||
json_schema TEXT NOT NULL,
|
||||
registered_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
schema_name TEXT NOT NULL,
|
||||
source_agent TEXT NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
meta_json TEXT,
|
||||
parent_artifact_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (schema_name) REFERENCES schemas(name),
|
||||
FOREIGN KEY (parent_artifact_id) REFERENCES artifacts(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_schema ON artifacts(schema_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_source ON artifacts(source_agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON artifacts(created_at);",
|
||||
];
|
||||
|
||||
/// Apply pending migrations. Uses pragma `user_version` as the version cursor.
|
||||
pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
let current: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
for (i, sql) in MIGRATIONS.iter().enumerate() {
|
||||
let target = (i + 1) as i64;
|
||||
if current < target {
|
||||
conn.execute_batch(sql)?;
|
||||
conn.pragma_update(None, "user_version", target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical list of artifact schema names shipped with this primitive.
|
||||
/// Also the whitelist the _assembler validator checks.
|
||||
pub const KNOWN_SCHEMAS: &[&str] = &["spec", "plan", "patch", "review", "research"];
|
||||
27
_primitives/_rust/kei-artifact/src/schemas.rs
Normal file
27
_primitives/_rust/kei-artifact/src/schemas.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//! Built-in schemas — 5 shipped schemas, embedded at compile time.
|
||||
//!
|
||||
//! Chain: architect(spec) → code-implementer(plan → patch) →
|
||||
//! critic/security(review) → researcher(research) feeds back.
|
||||
//! Each file lives in `kei-artifact/schemas/*.json` and is embedded via
|
||||
//! `include_str!` so the CLI `--self-register` path needs no filesystem.
|
||||
|
||||
use crate::artifact::register_schema;
|
||||
use crate::store::Store;
|
||||
use anyhow::Result;
|
||||
|
||||
/// (name, schema JSON text). Keep in sync with `schemas/*.json`.
|
||||
pub const BUILTIN: &[(&str, &str)] = &[
|
||||
("spec", include_str!("../schemas/spec.json")),
|
||||
("plan", include_str!("../schemas/plan.json")),
|
||||
("patch", include_str!("../schemas/patch.json")),
|
||||
("review", include_str!("../schemas/review.json")),
|
||||
("research", include_str!("../schemas/research.json")),
|
||||
];
|
||||
|
||||
/// Register all 5 built-in schemas. Idempotent.
|
||||
pub fn register_builtins(store: &Store) -> Result<()> {
|
||||
for (name, text) in BUILTIN {
|
||||
register_schema(store, name, text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
32
_primitives/_rust/kei-artifact/src/store.rs
Normal file
32
_primitives/_rust/kei-artifact/src/store.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//! `Store` — Connection wrapper. Single responsibility: open/migrate sqlite.
|
||||
|
||||
use crate::schema::migrate;
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Store {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path).context("open sqlite")?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL").ok();
|
||||
migrate(&conn)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn open_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
migrate(&conn)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
}
|
||||
198
_primitives/_rust/kei-artifact/src/validate.rs
Normal file
198
_primitives/_rust/kei-artifact/src/validate.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! Minimal JSON Schema validator — strict subset of draft 2020-12.
|
||||
//!
|
||||
//! Keyword support (chosen for the 5 built-in schemas):
|
||||
//! - `type` (object, array, string, integer, number, boolean, null)
|
||||
//! - `required` (array of property names)
|
||||
//! - `properties` (object → sub-schema)
|
||||
//! - `additionalProperties` (bool; default true, we set false on ours)
|
||||
//! - `enum` (array of allowed scalar values)
|
||||
//! - `items` (sub-schema for array elements)
|
||||
//! - `minLength` (integer) / `minItems` (integer) / `minimum` (number)
|
||||
//!
|
||||
//! Intentionally NOT supported: $ref, oneOf/anyOf/allOf, patternProperties,
|
||||
//! format validation, conditional schemas. The 5 built-in schemas are written
|
||||
//! to avoid needing those — keeps the validator under 200 LOC and removes the
|
||||
//! 40+ transitive-dep `jsonschema` crate.
|
||||
//!
|
||||
//! RULE 0.4 note: draft 2020-12 is the current JSON Schema standard
|
||||
//! [VERIFIED: https://json-schema.org/draft/2020-12 — spec page].
|
||||
//! This implementation is a strict subset — any schema author sticking to
|
||||
//! the keywords above gets draft-2020-12-compatible semantics.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Top-level entry. Returns `Ok(())` on pass, `Err(msg)` with a path-style
|
||||
/// location on first failure.
|
||||
pub fn validate_content(schema: &Value, content: &Value) -> Result<(), String> {
|
||||
check(schema, content, "$")
|
||||
}
|
||||
|
||||
fn check(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
||||
if let Some(t) = schema.get("type") {
|
||||
check_type(t, value, path)?;
|
||||
}
|
||||
if let Some(e) = schema.get("enum") {
|
||||
check_enum(e, value, path)?;
|
||||
}
|
||||
match value {
|
||||
Value::Object(_) => check_object(schema, value, path)?,
|
||||
Value::Array(_) => check_array(schema, value, path)?,
|
||||
Value::String(s) => check_min_length(schema, s, path)?,
|
||||
Value::Number(n) => check_minimum(schema, n, path)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_type(schema_type: &Value, value: &Value, path: &str) -> Result<(), String> {
|
||||
let want = schema_type
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("{path}: schema 'type' must be string"))?;
|
||||
let ok = match (want, value) {
|
||||
("object", Value::Object(_)) => true,
|
||||
("array", Value::Array(_)) => true,
|
||||
("string", Value::String(_)) => true,
|
||||
("boolean", Value::Bool(_)) => true,
|
||||
("null", Value::Null) => true,
|
||||
("integer", Value::Number(n)) => n.is_i64() || n.is_u64(),
|
||||
("number", Value::Number(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
return Err(format!(
|
||||
"{path}: expected type '{want}', got {}",
|
||||
type_of(value)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn type_of(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn check_enum(enum_schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
||||
let allowed = enum_schema
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("{path}: 'enum' must be array"))?;
|
||||
if !allowed.iter().any(|a| a == value) {
|
||||
return Err(format!("{path}: value {value} not in enum"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_object(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
||||
let obj = value.as_object().unwrap();
|
||||
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
|
||||
for r in required {
|
||||
if let Some(name) = r.as_str() {
|
||||
if !obj.contains_key(name) {
|
||||
return Err(format!("{path}: missing required property '{name}'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let props = schema.get("properties").and_then(|v| v.as_object());
|
||||
let additional = schema
|
||||
.get("additionalProperties")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
for (k, v) in obj {
|
||||
match props.and_then(|p| p.get(k)) {
|
||||
Some(sub) => check(sub, v, &format!("{path}.{k}"))?,
|
||||
None if !additional => {
|
||||
return Err(format!("{path}: unexpected property '{k}'"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_array(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
||||
let arr = value.as_array().unwrap();
|
||||
if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
|
||||
if (arr.len() as u64) < min {
|
||||
return Err(format!("{path}: array has {} items, min {min}", arr.len()));
|
||||
}
|
||||
}
|
||||
if let Some(items) = schema.get("items") {
|
||||
for (i, el) in arr.iter().enumerate() {
|
||||
check(items, el, &format!("{path}[{i}]"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_min_length(schema: &Value, s: &str, path: &str) -> Result<(), String> {
|
||||
if let Some(min) = schema.get("minLength").and_then(|v| v.as_u64()) {
|
||||
if (s.chars().count() as u64) < min {
|
||||
return Err(format!("{path}: string shorter than minLength {min}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_minimum(schema: &Value, n: &serde_json::Number, path: &str) -> Result<(), String> {
|
||||
if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
|
||||
if let Some(v) = n.as_f64() {
|
||||
if v < min {
|
||||
return Err(format!("{path}: number {v} below minimum {min}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn type_mismatch_rejected() {
|
||||
let schema = json!({"type": "string"});
|
||||
let err = validate_content(&schema, &json!(42)).unwrap_err();
|
||||
assert!(err.contains("expected type 'string'"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_rejected() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"required": ["goal"],
|
||||
"properties": {"goal": {"type": "string"}}
|
||||
});
|
||||
let err = validate_content(&schema, &json!({})).unwrap_err();
|
||||
assert!(err.contains("goal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_additional_rejected() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {"a": {"type": "string"}}
|
||||
});
|
||||
let err = validate_content(&schema, &json!({"a":"x","b":"y"})).unwrap_err();
|
||||
assert!(err.contains("unexpected property 'b'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_and_array_items_enforced() {
|
||||
let schema = json!({
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["add", "mod", "del"]}
|
||||
});
|
||||
assert!(validate_content(&schema, &json!(["add", "mod"])).is_ok());
|
||||
let err = validate_content(&schema, &json!(["nope"])).unwrap_err();
|
||||
assert!(err.contains("enum"));
|
||||
}
|
||||
}
|
||||
132
_primitives/_rust/kei-artifact/tests/integration.rs
Normal file
132
_primitives/_rust/kei-artifact/tests/integration.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! Integration tests — core CRUD (register_schema / emit / get / list / chain).
|
||||
//!
|
||||
//! Constructor Pattern: each test = one scenario, one assertion focus.
|
||||
//! Companion file `validation.rs` tests schema-validation edge cases.
|
||||
|
||||
use kei_artifact::artifact::{chain, emit, get, list, register_schema, ArtifactFilter};
|
||||
use kei_artifact::hash::artifact_id;
|
||||
use kei_artifact::schemas::{register_builtins, BUILTIN};
|
||||
use kei_artifact::Store;
|
||||
use serde_json::json;
|
||||
|
||||
fn seed() -> Store {
|
||||
let s = Store::open_memory().unwrap();
|
||||
register_builtins(&s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn spec_bytes(goal: &str) -> Vec<u8> {
|
||||
serde_json::to_vec(&json!({
|
||||
"goal": goal, "constraints": [], "invariants": []
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_builtin_schemas_and_list_them() {
|
||||
let s = seed();
|
||||
let names = kei_artifact::artifact::list_schemas(&s).unwrap();
|
||||
for (n, _) in BUILTIN {
|
||||
assert!(names.iter().any(|x| x == n), "missing {n}");
|
||||
}
|
||||
assert_eq!(names.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_schema_custom_and_query_back() {
|
||||
let s = seed();
|
||||
let custom = r#"{"type":"object","additionalProperties":false,"properties":{}}"#;
|
||||
register_schema(&s, "custom", custom).unwrap();
|
||||
let names = kei_artifact::artifact::list_schemas(&s).unwrap();
|
||||
assert!(names.iter().any(|n| n == "custom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_get_roundtrip_for_spec_schema() {
|
||||
let s = seed();
|
||||
let bytes = spec_bytes("ship v0.15");
|
||||
let id = emit(&s, "spec", "kei-architect", &bytes, None, None).unwrap();
|
||||
let got = get(&s, &id).unwrap().unwrap();
|
||||
assert_eq!(got.schema_name, "spec");
|
||||
assert_eq!(got.source_agent, "kei-architect");
|
||||
assert_eq!(got.content, bytes);
|
||||
assert_eq!(got.id, artifact_id("spec", &bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_walks_parent_handoff_up_the_graph() {
|
||||
let s = seed();
|
||||
let spec = spec_bytes("g");
|
||||
let spec_id = emit(&s, "spec", "kei-architect", &spec, None, None).unwrap();
|
||||
|
||||
let plan = serde_json::to_vec(&json!({
|
||||
"goal": "g",
|
||||
"steps": [{"step": "s1", "verify": "v1"}]
|
||||
}))
|
||||
.unwrap();
|
||||
let plan_id = emit(&s, "plan", "kei-architect", &plan, None, Some(&spec_id)).unwrap();
|
||||
|
||||
let patch = serde_json::to_vec(&json!({
|
||||
"summary": "first cut",
|
||||
"changes": [{"path": "a.rs", "op": "add", "summary": "new"}]
|
||||
}))
|
||||
.unwrap();
|
||||
let patch_id = emit(&s, "patch", "kei-code-implementer", &patch, None, Some(&plan_id)).unwrap();
|
||||
|
||||
let walk = chain(&s, &patch_id).unwrap();
|
||||
assert_eq!(walk.len(), 3);
|
||||
assert_eq!(walk[0].id, spec_id);
|
||||
assert_eq!(walk[1].id, plan_id);
|
||||
assert_eq!(walk[2].id, patch_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_filters_by_schema_source_and_since() {
|
||||
let s = seed();
|
||||
let a = spec_bytes("a");
|
||||
let b = spec_bytes("b");
|
||||
emit(&s, "spec", "kei-architect", &a, None, None).unwrap();
|
||||
emit(&s, "spec", "kei-researcher", &b, None, None).unwrap();
|
||||
|
||||
let by_source = list(&s, &ArtifactFilter {
|
||||
source_agent: Some("kei-architect".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(by_source.len(), 1);
|
||||
assert_eq!(by_source[0].source_agent, "kei-architect");
|
||||
|
||||
let by_schema = list(&s, &ArtifactFilter {
|
||||
schema_name: Some("spec".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(by_schema.len(), 2);
|
||||
|
||||
let none = list(&s, &ArtifactFilter {
|
||||
schema_name: Some("plan".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_emit_is_idempotent_same_id() {
|
||||
let s = seed();
|
||||
let content = spec_bytes("g");
|
||||
let id1 = emit(&s, "spec", "kei-architect", &content, None, None).unwrap();
|
||||
let id2 = emit(&s, "spec", "kei-architect", &content, None, None).unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
let all = list(&s, &ArtifactFilter::default()).unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_rejected() {
|
||||
let s = seed();
|
||||
let content = spec_bytes("g");
|
||||
let err = emit(&s, "spec", "kei-architect", &content, None, Some("deadbeef")).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("parent"), "unexpected: {msg}");
|
||||
}
|
||||
139
_primitives/_rust/kei-artifact/tests/validation.rs
Normal file
139
_primitives/_rust/kei-artifact/tests/validation.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
//! Integration tests — schema validation edge cases.
|
||||
//!
|
||||
//! Constructor Pattern: one scenario per test, one assertion focus.
|
||||
|
||||
use kei_artifact::artifact::{emit, get, register_schema, validate_by_id};
|
||||
use kei_artifact::schemas::register_builtins;
|
||||
use kei_artifact::Store;
|
||||
use serde_json::json;
|
||||
|
||||
fn seed() -> Store {
|
||||
let s = Store::open_memory().unwrap();
|
||||
register_builtins(&s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_content_rejected_at_emit_time() {
|
||||
let s = seed();
|
||||
// spec requires goal, constraints, invariants — give only goal.
|
||||
let bad = serde_json::to_vec(&json!({"goal": "x"})).unwrap();
|
||||
let err = emit(&s, "spec", "kei-architect", &bad, None, None).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("constraints") || msg.contains("invariants"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_by_id_passes_for_conforming_content() {
|
||||
let s = seed();
|
||||
let content = serde_json::to_vec(&json!({
|
||||
"summary": "ok",
|
||||
"changes": [{"path": "x", "op": "mod", "summary": "tiny"}]
|
||||
}))
|
||||
.unwrap();
|
||||
let id = emit(&s, "patch", "kei-code-implementer", &content, None, None).unwrap();
|
||||
assert!(validate_by_id(&s, &id).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_by_id_detects_drift_after_schema_override() {
|
||||
let s = seed();
|
||||
let content = serde_json::to_vec(&json!({
|
||||
"summary": "ok",
|
||||
"changes": [{"path": "x", "op": "mod", "summary": "t"}]
|
||||
}))
|
||||
.unwrap();
|
||||
let id = emit(&s, "patch", "kei-code-implementer", &content, None, None).unwrap();
|
||||
let stricter = r#"{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["summary", "changes", "must_have"],
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"changes": {"type": "array"},
|
||||
"must_have": {"type": "string"}
|
||||
}
|
||||
}"#;
|
||||
register_schema(&s, "patch", stricter).unwrap();
|
||||
let err = validate_by_id(&s, &id).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("must_have"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_name_rejected_at_emit() {
|
||||
let s = seed();
|
||||
let err = emit(&s, "not-a-real-schema", "x", b"{}", None, None).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("unknown schema"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_schema_accepts_canonical_critic_output() {
|
||||
let s = seed();
|
||||
let content = serde_json::to_vec(&json!({
|
||||
"reviewer": "kei-critic",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "high",
|
||||
"category": "bug",
|
||||
"title": "off-by-one",
|
||||
"file": "src/x.rs",
|
||||
"line": 42
|
||||
}
|
||||
],
|
||||
"verdict": "request_changes"
|
||||
}))
|
||||
.unwrap();
|
||||
let id = emit(&s, "review", "kei-critic", &content, None, None).unwrap();
|
||||
let back = get(&s, &id).unwrap().unwrap();
|
||||
assert_eq!(back.schema_name, "review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn research_schema_accepts_claims_with_evidence_grade() {
|
||||
let s = seed();
|
||||
let content = serde_json::to_vec(&json!({
|
||||
"question": "Does Rust's cargo support offline builds?",
|
||||
"claims": [
|
||||
{
|
||||
"claim": "cargo --offline works with pre-cached deps",
|
||||
"evidence_grade": "E1",
|
||||
"confidence": "100",
|
||||
"sources": [{"url": "https://doc.rust-lang.org/cargo/", "verified": true}]
|
||||
}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let id = emit(&s, "research", "kei-researcher", &content, None, None).unwrap();
|
||||
assert!(validate_by_id(&s, &id).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_schema_rejects_empty_steps() {
|
||||
let s = seed();
|
||||
let bad = serde_json::to_vec(&json!({
|
||||
"goal": "g",
|
||||
"steps": []
|
||||
}))
|
||||
.unwrap();
|
||||
let err = emit(&s, "plan", "kei-architect", &bad, None, None).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("array"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_schema_rejects_invalid_op_enum() {
|
||||
let s = seed();
|
||||
let bad = serde_json::to_vec(&json!({
|
||||
"summary": "ok",
|
||||
"changes": [{"path": "x", "op": "RENAME", "summary": "t"}]
|
||||
}))
|
||||
.unwrap();
|
||||
let err = emit(&s, "patch", "kei-code-implementer", &bad, None, None).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("enum"), "unexpected: {msg}");
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ via Phase 3 grep, but surface it explicitly so the user sees the option:
|
|||
- Test matrix (unit / integration / e2e / visual) → `/test-matrix`
|
||||
- Frontend site / UI WYSIWYD loop → `/site-create` + `mock-render` + `visual-diff` + `tokens-sync`
|
||||
- Multi-agent project bootstrap → `/new-project` + `kei-ledger` (RULE 0.12 fork tracking)
|
||||
- Typed artifact handoff between agents → `kei-artifact` (v0.15: schema-validated spec→plan→patch→review chain instead of prose hints). If your architecture spans multiple agents and the output of one is the input of another, declare `produces_artifact` / `expects_artifact` in the manifest and emit via `kei-artifact emit`.
|
||||
|
||||
One-line per reference, click-discoverable, no duplication of pipeline logic.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue