From 537589e6a7a826d10c59c9d5a4f2457f4e1ef962 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Wed, 22 Apr 2026 14:10:08 +0800 Subject: [PATCH] feat(primitives): kei-artifact typed handoff pipeline (BMAD-style doc passthrough) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- _assembler/src/main.rs | 1 + _assembler/src/manifest.rs | 10 + _assembler/src/placeholders.rs | 124 +++++++++++ _assembler/src/validator.rs | 123 +++++------ _manifests/kei-architect.toml | 5 + _manifests/kei-code-implementer.toml | 5 + _manifests/kei-critic.toml | 6 + _primitives/MANIFEST.toml | 12 +- _primitives/_rust/Cargo.lock | 14 ++ _primitives/_rust/Cargo.toml | 2 + _primitives/_rust/kei-artifact/Cargo.toml | 26 +++ .../_rust/kei-artifact/schemas/patch.json | 48 +++++ .../_rust/kei-artifact/schemas/plan.json | 37 ++++ .../_rust/kei-artifact/schemas/research.json | 46 ++++ .../_rust/kei-artifact/schemas/review.json | 41 ++++ .../_rust/kei-artifact/schemas/spec.json | 40 ++++ .../_rust/kei-artifact/src/artifact.rs | 191 +++++++++++++++++ _primitives/_rust/kei-artifact/src/hash.rs | 54 +++++ _primitives/_rust/kei-artifact/src/lib.rs | 24 +++ _primitives/_rust/kei-artifact/src/main.rs | 198 ++++++++++++++++++ _primitives/_rust/kei-artifact/src/schema.rs | 50 +++++ _primitives/_rust/kei-artifact/src/schemas.rs | 27 +++ _primitives/_rust/kei-artifact/src/store.rs | 32 +++ .../_rust/kei-artifact/src/validate.rs | 198 ++++++++++++++++++ .../_rust/kei-artifact/tests/integration.rs | 132 ++++++++++++ .../_rust/kei-artifact/tests/validation.rs | 139 ++++++++++++ .../compose-solution/phase-5-architecture.md | 1 + 27 files changed, 1517 insertions(+), 69 deletions(-) create mode 100644 _assembler/src/placeholders.rs create mode 100644 _primitives/_rust/kei-artifact/Cargo.toml create mode 100644 _primitives/_rust/kei-artifact/schemas/patch.json create mode 100644 _primitives/_rust/kei-artifact/schemas/plan.json create mode 100644 _primitives/_rust/kei-artifact/schemas/research.json create mode 100644 _primitives/_rust/kei-artifact/schemas/review.json create mode 100644 _primitives/_rust/kei-artifact/schemas/spec.json create mode 100644 _primitives/_rust/kei-artifact/src/artifact.rs create mode 100644 _primitives/_rust/kei-artifact/src/hash.rs create mode 100644 _primitives/_rust/kei-artifact/src/lib.rs create mode 100644 _primitives/_rust/kei-artifact/src/main.rs create mode 100644 _primitives/_rust/kei-artifact/src/schema.rs create mode 100644 _primitives/_rust/kei-artifact/src/schemas.rs create mode 100644 _primitives/_rust/kei-artifact/src/store.rs create mode 100644 _primitives/_rust/kei-artifact/src/validate.rs create mode 100644 _primitives/_rust/kei-artifact/tests/integration.rs create mode 100644 _primitives/_rust/kei-artifact/tests/validation.rs diff --git a/_assembler/src/main.rs b/_assembler/src/main.rs index 5668940..d2970fd 100644 --- a/_assembler/src/main.rs +++ b/_assembler/src/main.rs @@ -7,6 +7,7 @@ mod assembler; mod manifest; +mod placeholders; mod validator; use manifest::Manifest; diff --git a/_assembler/src/manifest.rs b/_assembler/src/manifest.rs index 971c019..38df9eb 100644 --- a/_assembler/src/manifest.rs +++ b/_assembler/src/manifest.rs @@ -19,12 +19,22 @@ pub struct Manifest { pub memory_project: Option, pub project_claudemd: Option, pub references: Option, + /// 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, } #[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, + /// v0.15: optional schema name this agent produces for the target. + #[serde(default)] + pub produces_artifact: Option, } #[derive(Deserialize)] diff --git a/_assembler/src/placeholders.rs b/_assembler/src/placeholders.rs new file mode 100644 index 0000000..e483795 --- /dev/null +++ b/_assembler/src/placeholders.rs @@ -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()); + } +} diff --git a/_assembler/src/validator.rs b/_assembler/src/validator.rs index e59864c..d526f3b 100644 --- a/_assembler/src/validator.rs +++ b/_assembler/src/validator.rs @@ -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}"); } } diff --git a/_manifests/kei-architect.toml b/_manifests/kei-architect.toml index 5124cc5..1fe518b 100644 --- a/_manifests/kei-architect.toml +++ b/_manifests/kei-architect.toml @@ -64,10 +64,15 @@ output_extra_fields = [ "Decisive verdict: ", ] +# 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" diff --git a/_manifests/kei-code-implementer.toml b/_manifests/kei-code-implementer.toml index 39bd4a6..8842778 100644 --- a/_manifests/kei-code-implementer.toml +++ b/_manifests/kei-code-implementer.toml @@ -63,6 +63,10 @@ output_extra_fields = [ "Checkpoints: ", ] +# 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" diff --git a/_manifests/kei-critic.toml b/_manifests/kei-critic.toml index 5dc194a..fbd0c17 100644 --- a/_manifests/kei-critic.toml +++ b/_manifests/kei-critic.toml @@ -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" diff --git a/_primitives/MANIFEST.toml b/_primitives/MANIFEST.toml index 71e3a55..58fd48f 100644 --- a/_primitives/MANIFEST.toml +++ b/_primitives/MANIFEST.toml @@ -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)" diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 1d84aa8..fee0548 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -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" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 36e041c..8797803 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -26,6 +26,8 @@ members = [ "kei-social-store", "kei-curator", "kei-auth", + # v0.15 artifact handoff pipeline + "kei-artifact", ] [workspace.package] diff --git a/_primitives/_rust/kei-artifact/Cargo.toml b/_primitives/_rust/kei-artifact/Cargo.toml new file mode 100644 index 0000000..40b7394 --- /dev/null +++ b/_primitives/_rust/kei-artifact/Cargo.toml @@ -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" diff --git a/_primitives/_rust/kei-artifact/schemas/patch.json b/_primitives/_rust/kei-artifact/schemas/patch.json new file mode 100644 index 0000000..9f86d2e --- /dev/null +++ b/_primitives/_rust/kei-artifact/schemas/patch.json @@ -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"} + } + } + } + } +} diff --git a/_primitives/_rust/kei-artifact/schemas/plan.json b/_primitives/_rust/kei-artifact/schemas/plan.json new file mode 100644 index 0000000..5910b17 --- /dev/null +++ b/_primitives/_rust/kei-artifact/schemas/plan.json @@ -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} + } + } +} diff --git a/_primitives/_rust/kei-artifact/schemas/research.json b/_primitives/_rust/kei-artifact/schemas/research.json new file mode 100644 index 0000000..126651b --- /dev/null +++ b/_primitives/_rust/kei-artifact/schemas/research.json @@ -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} + } + } +} diff --git a/_primitives/_rust/kei-artifact/schemas/review.json b/_primitives/_rust/kei-artifact/schemas/review.json new file mode 100644 index 0000000..8d76070 --- /dev/null +++ b/_primitives/_rust/kei-artifact/schemas/review.json @@ -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"] + } + } +} diff --git a/_primitives/_rust/kei-artifact/schemas/spec.json b/_primitives/_rust/kei-artifact/schemas/spec.json new file mode 100644 index 0000000..a2f6875 --- /dev/null +++ b/_primitives/_rust/kei-artifact/schemas/spec.json @@ -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"] + } + } +} diff --git a/_primitives/_rust/kei-artifact/src/artifact.rs b/_primitives/_rust/kei-artifact/src/artifact.rs new file mode 100644 index 0000000..c5a6399 --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/artifact.rs @@ -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, + pub meta_json: Option, + pub parent_artifact_id: Option, + pub created_at: i64, +} + +#[derive(Debug, Default, Clone)] +pub struct ArtifactFilter { + pub schema_name: Option, + pub source_agent: Option, + pub since: Option, +} + +/// 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> { + let mut stmt = store.conn().prepare("SELECT name FROM schemas ORDER BY name")?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::>>()?; + Ok(rows) +} + +fn load_schema(store: &Store, name: &str) -> Result { + 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 { + 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 { + 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> { + 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 { + 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> { + 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::>>()?; + Ok(rows) +} + +fn build_list_sql(f: &ArtifactFilter) -> (String, Vec) { + 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 = Vec::new(); + let mut clauses: Vec = 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> { + let mut out: Vec = 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) +} diff --git a/_primitives/_rust/kei-artifact/src/hash.rs b/_primitives/_rust/kei-artifact/src/hash.rs new file mode 100644 index 0000000..51f78b2 --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/hash.rs @@ -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); + } +} diff --git a/_primitives/_rust/kei-artifact/src/lib.rs b/_primitives/_rust/kei-artifact/src/lib.rs new file mode 100644 index 0000000..401cb78 --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/lib.rs @@ -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; diff --git a/_primitives/_rust/kei-artifact/src/main.rs b/_primitives/_rust/kei-artifact/src/main.rs new file mode 100644 index 0000000..699fdaa --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/main.rs @@ -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, + #[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, + #[arg(long)] parent: Option, + }, + /// 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, + #[arg(long)] from: Option, + #[arg(long)] since: Option, + }, + /// 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 { + 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 { + 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(""); + 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 { + // Accept raw epoch ints, or "1d" / "2h" / "30m" shorthands. + if let Ok(n) = s.parse::() { + 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) + } + } +} diff --git a/_primitives/_rust/kei-artifact/src/schema.rs b/_primitives/_rust/kei-artifact/src/schema.rs new file mode 100644 index 0000000..3ef862f --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/schema.rs @@ -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"]; diff --git a/_primitives/_rust/kei-artifact/src/schemas.rs b/_primitives/_rust/kei-artifact/src/schemas.rs new file mode 100644 index 0000000..556ad2b --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/schemas.rs @@ -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(()) +} diff --git a/_primitives/_rust/kei-artifact/src/store.rs b/_primitives/_rust/kei-artifact/src/store.rs new file mode 100644 index 0000000..13d8f8a --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/store.rs @@ -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 { + 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 { + let conn = Connection::open_in_memory()?; + migrate(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { + &self.conn + } +} diff --git a/_primitives/_rust/kei-artifact/src/validate.rs b/_primitives/_rust/kei-artifact/src/validate.rs new file mode 100644 index 0000000..15cde5f --- /dev/null +++ b/_primitives/_rust/kei-artifact/src/validate.rs @@ -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")); + } +} diff --git a/_primitives/_rust/kei-artifact/tests/integration.rs b/_primitives/_rust/kei-artifact/tests/integration.rs new file mode 100644 index 0000000..d1ced04 --- /dev/null +++ b/_primitives/_rust/kei-artifact/tests/integration.rs @@ -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 { + 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}"); +} diff --git a/_primitives/_rust/kei-artifact/tests/validation.rs b/_primitives/_rust/kei-artifact/tests/validation.rs new file mode 100644 index 0000000..ea10f8b --- /dev/null +++ b/_primitives/_rust/kei-artifact/tests/validation.rs @@ -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}"); +} diff --git a/skills/compose-solution/phase-5-architecture.md b/skills/compose-solution/phase-5-architecture.md index 6c6706f..209e9e4 100644 --- a/skills/compose-solution/phase-5-architecture.md +++ b/skills/compose-solution/phase-5-architecture.md @@ -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.