refactor(wave17): cleanup — kei-shared SSoT + MEDIUM audit residuals + docs drift

47 crates, 771 tests green (up from 753 at v0.33.0). Zero new
features — pure hygiene.

## kei-shared extract (SSoT for DNA format)

New crate `kei-shared` consolidates DNA-parse logic that was duplicated
across kei-agent-runtime + kei-dna-index. Both consumers migrated to
import ParsedDna / parse_dna / is_hex8 from kei_shared.

- 12 tests (10 integration + 2 unit)
- kei-dna-index LOC reduction: -60 in parsed.rs (body replaced by wrapper)
- kei-agent-runtime preserves lenient DnaError (legacy 4-hex parse path)
- Format-string SSoT: kei_shared::compose_dna is sole source

## MEDIUM audit residuals closed (kei-entity-store)

A. DDL panic coverage — verified exhaustive match across all 12
   FieldKind variants; new test ddl_never_panics_on_any_fieldkind
   compile-time-breaks if a variant added without test update.
B. Update FTS reindex invariant — doc + new update_invariant.rs module
   with debug_assert validating non-input FTS columns don't drift
   pre/post UPDATE. Zero release-mode cost (cfg-gated).
C. WAL fallback — wal_pragma_fallback_keeps_store_usable test (cfg(unix))
   verifies read-only-parent dir doesn't brick Store::open.
D. Search Unicode edge cases — 4 new tests (punctuation, emoji,
   zero-width, mixed RTL). has_searchable_token already correct, no
   source change needed; tests pin current behavior.

Added: residual_audit_smoke.rs (8 tests), update_invariant.rs module.
kei-entity-store: 57 → 65 tests.

## Docs drift fixed (count claims → reality)

- README.md: "36 crates → 47 crates", "500+ tests → 800+ tests"
- PLUGIN.md, docs/INSTALL.md, docs/REFERENCE.md, docs/SUBSTRATE-SCHEMA.md
  all synced to real counts.
- CHANGELOG.md: 6 new version blocks (v0.28 → v0.33) consolidated
  in existing style.
- Historical snapshots (HANDOFF-WAKE v0.29, CONVERGENCE-PLAN, etc)
  deliberately preserved — they're version-scoped, not drift.

## Known deviation from task spec

kei-shared's [workspace] table was dropped (Cargo rejected "multiple
workspace roots" when parent workspace pulls via path dep). Crate
registered in workspace.members instead. Verified cargo check + test
clean in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 20:34:43 +08:00
parent f5bbf7193e
commit 9622d41bb6
21 changed files with 918 additions and 100 deletions

View file

@ -114,6 +114,81 @@ _primitives/_rust/target/release/kei-changelog \
- Added `.github/dependabot.yml` for weekly SHA update PRs on github-actions, npm, and cargo ecosystems.
- **v0.20.1 — workflow validation defense-in-depth:** motivated by the 2026-04-22 incident where `dtolnay/rust-toolchain@3c5f7ea...` SHA-pinned a specific Rust version (1.94.1 branch tip) instead of "install current stable", breaking CI for 4 jobs. Added three gates against the incident class: `scripts/install-actionlint.sh` (pinned v1.7.12 installer, macOS-arm64 + linux-x64), `scripts/lint-workflows.sh` (actionlint runner, advisory if binary missing), `scripts/validate-workflow-shas.sh` (git-ls-remote every `uses: <repo>@<sha40>` pin; exits 1 on `SHA MISSING`, soft-continues on network errors with `[UNVERIFIED]`), `scripts/pre-commit-workflow-lint.sh` (symlink-to-install pre-commit hook, fires only when workflow files are staged), and new `workflow-lint` CI job running the two validators on every push + PR.
## [0.33.0] — 2026-04-23
Wave 16 — parallel consumer wiring. Ledger v6 + cluster-aware prune + DNA-driven fork precedent + three-role pipeline. Consolidates v0.28 → v0.33.
### Added
- **primitives:** `kei-fork` watch-hook auto-collects on `.DONE` marker
- **primitives:** `kei-prune` cluster-based retirement via `kei-dna-index` clusters
- **primitives:** `kei-brain-view` cluster + summary visualization
- **pipeline:** three-role pipeline (Writer → Auditor → Merger) with precedent pre-check
### Changed
- **kei-ledger:** schema v6 — 3 performance indexes + `fork_transactional` library API
### Snapshot
- 47 crates workspace
- 800+ tests green total
## [0.32.0] — 2026-04-23
Wave 15 Option D — DNA adjacency + managed fork primitive.
### Added
- **primitives:** `kei-dna-index` — read-only adjacency / cluster / precedent view over the ledger
- **primitives:** `kei-fork` — managed git-worktree + ledger lifecycle
### Changed
- **kei-fork:** root path moved to `_forks/` (sandbox-writable, kit convention)
## [0.31.0] — 2026-04-23
Wave 15 foundation — spawn hardening + entity-store medium fixes.
### Added
- **kei-spawn:** `HttpDriver` feature-flag behind `http-driver`
### Fixed
- **security:** `agent_id` path-traversal validator + `safe_join` hardening
- **kei-entity-store:** medium audit fixes (ddl panic, search empty-token, WAL logging)
## [0.30.0] — 2026-04-23
Wave 14 — bio-inspired primitives.
### Added
- **primitives:** `kei-prune` — bio-inspired retirement of idle agents
- **primitives:** `kei-discover` — federated marketplace discovery stub
- **primitives:** `kei-brain-view` — brain-state visualizer
- **primitives:** `kei-hibernate` — agent hibernation / reawaken lifecycle
- **primitives:** `kei-ledger-sign` — signing + verification for ledger rows
### Snapshot
- 44 crates workspace
- 713 tests green
## [0.29.0] — 2026-04-22
Wave 13 — structural diff, scheduler, watcher + HIGH audit fixes.
### Added
- **primitives:** `kei-diff` — RFC 6902 JSON Patch subset (add / remove / replace)
- **primitives:** `kei-scheduler` — cron / at / interval metadata primitive
- **primitives:** `kei-watch` — filesystem watcher (thin `notify` wrapper, sync API)
### Fixed
- **fts:** delete-transaction + archive FTS desync fixed
- **kei-dna-index:** `UNIQUE` constraint (v5 migration)
## [0.28.0] — 2026-04-23
Wave 12 — count refresh + content-store engine promotion.
### Changed
- **kei-content-store:** `CAMPAIGNS_SCHEMA` promoted to engine
- **docs:** counts refreshed across README / INSTALL / REFERENCE after v0.23 → v0.27 cluster
## [0.15.0] — 2026-04-22
### Added

View file

@ -38,7 +38,7 @@ Paths inside `hooks/hooks.json` use `${CLAUDE_PLUGIN_ROOT}` (expanded by Claude
| Skills registered | yes, automatic | yes, copied to `~/.claude/skills/` |
| Hooks wired | yes, via `hooks/hooks.json` | requires `--activate-hooks` (jq-merge of `settings-snippet.json`) |
| MCP server | yes, via `.mcp.json` (once `@keisei/mcp-server` is published) | same |
| 36 Rust primitives | **no** — plugin ships manifest sources only; no cargo build | yes, `--profile=<name>` builds the selected set |
| 47 Rust primitives | **no** — plugin ships manifest sources only; no cargo build | yes, `--profile=<name>` builds the selected set |
| 13 shell primitives | **no** | yes, copied to `~/.claude/agents/_primitives/` |
| Disk footprint | ~2 MB (plugin cache) | ~2 MB minimal up to ~200 MB full |
| Update path | `/plugin update keisei` | `git pull && ./install.sh` |

View file

@ -48,14 +48,14 @@ Layer 1 is the body — the reusable parts. Layer 2 is identity and memory. Laye
## What ships (verified counts)
- **36 Rust primitives** — pure Rust workspace crates, release-stripped, each ≤2 MB, no Python runtime. Covers the ledger, memory, router, migrate, agent runtime, forge, spawn/replay, sleep infrastructure, and the `keisei` CLI.
- **47 Rust primitives** — pure Rust workspace crates, release-stripped, each ≤2 MB, no Python runtime. Covers the ledger, memory, router, migrate, agent runtime, forge, spawn/replay, fork lifecycle, DNA adjacency/cluster index, pruning + discovery + brain-view, sleep infrastructure, and the `keisei` CLI.
- **12 agents** (`kei-*` namespaced) — code-implementer, infra-implementer, ml-implementer, critic, validator, security-auditor, architect, researcher, ml-researcher, cost-guardian, modal-runner, fal-ai-runner. All carry a `substrate_role` facet.
- **43 skills** — one-command pipelines including `/new-project`, `/spawn-agent`, `/self-audit`, `/sleep-on-it`, `/sleep-setup`, `/compose-solution`, `/schema-design`, `/api-design`, `/auth-setup`, `/observability-setup`, `/ci-scaffold`, `/pr-review`, `/debug-deep`.
- **12 hooks** — pre-commit safety net, always on: assembler, validator, no-hand-edit-agents, tomd-preread, agent-fork-logger, orchestrator-dirty-check, site-wysiwyd-check, session-end-dump, milestone-commit-hook, error-spike-detector, and two capability gates.
- **82 behavioural blocks** — tested patterns composable into your own agents via `blocks = [...]` in a manifest.
- **11 capabilities / 5 roles** — the capability-graph that agents resolve at spawn time.
- **11 cross-tool bridges** — one source of truth emits `.cursorrules`, Cursor MDC, `AGENTS.md`, Copilot, Windsurf, Junie, Continue, Gemini, Aider, Replit. Switch AI tools without rewriting your setup.
- **500+ tests** across the Rust workspace, green on `cargo test --workspace` on every supported OS.
- **800+ tests** across the Rust workspace, green on `cargo test --workspace` on every supported OS.
Every number is regenerated from source by `scripts/regen-counts.sh` — no manual drift.

View file

@ -2337,6 +2337,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"kei-shared",
"once_cell",
"rand 0.8.6",
"regex",
@ -2538,6 +2539,7 @@ name = "kei-dna-index"
version = "0.1.0"
dependencies = [
"clap",
"kei-shared",
"rusqlite",
"serde",
"serde_json",
@ -2808,6 +2810,14 @@ dependencies = [
"tempfile",
]
[[package]]
name = "kei-shared"
version = "0.1.0"
dependencies = [
"serde",
"thiserror 1.0.69",
]
[[package]]
name = "kei-social-store"
version = "0.1.0"

View file

@ -69,6 +69,8 @@ members = [
"kei-ledger-sign",
# v0.31 Wave 15 — managed git worktree + ledger lifecycle (fork/collect/gc/rescue)
"kei-fork",
# v0.34 Wave 17 — SSoT for DNA format + shared substrate types
"kei-shared",
# v0.32 Wave 15 — read-only DNA adjacency/cluster/precedent over kei-ledger
"kei-dna-index",
]

View file

@ -25,6 +25,7 @@ once_cell = "1"
walkdir = "2"
sha2 = { workspace = true }
rand = "0.8"
kei-shared = { path = "../kei-shared" }
[dev-dependencies]
tempfile = "3"

View file

@ -20,12 +20,23 @@
//! so future schema extensions don't break old ledger rows. For rolling
//! upgrade, 4-hex legacy hash/nonce values still parse silently — the
//! fallback is a successful parse path, not an error.
//!
//! Wire-format SSoT lives in `kei_shared::dna` — `render()` delegates to
//! `kei_shared::compose_dna` so the format string exists in one place.
//! Strict parser primitives from `kei_shared` (`parse_dna`, `ParsedDna`,
//! `is_hex8`) are re-exported for callers that want width validation;
//! the in-crate lenient `Dna::parse` stays for rolling-upgrade support.
use crate::capability::TaskSpec;
use crate::role::ResolvedRole;
use sha2::{Digest, Sha256};
use thiserror::Error;
/// Re-export of the strict wire-format parser from `kei_shared::dna`.
/// Callers needing 8-hex width validation (e.g. kei-dna-index) use these;
/// rolling-upgrade callers use the lenient [`Dna::parse`] below.
pub use kei_shared::dna::{is_hex8, parse_dna, ParsedDna};
/// Capability-name → 2-char atom code lookup.
///
/// Stable, extensible — additions allowed; removals NOT. `compose` emits
@ -55,7 +66,12 @@ pub struct Dna {
pub nonce: String,
}
/// Error during DNA parsing.
/// Error during lenient rolling-upgrade DNA parsing.
///
/// Distinct from [`kei_shared::dna::DnaError`]: this variant is lenient
/// (accepts legacy 4-hex segment widths), and shape-failure is the only
/// error class. Segment-content validation is deferred to callers that
/// care about widths — they can re-parse with `kei_shared::parse_dna`.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DnaError {
#[error("DNA string must have 4 `::` segments and `<body>-<nonce>` tail")]
@ -80,11 +96,15 @@ impl Dna {
}
}
/// Render to the canonical wire format.
/// Render to the canonical wire format. Delegates the format-string
/// SSoT to `kei_shared::dna::compose_dna`.
pub fn render(&self) -> String {
format!(
"{}::{}::{}::{}-{}",
self.role, self.caps_bitmap, self.scope_hash, self.body_hash, self.nonce
kei_shared::dna::compose_dna(
&self.role,
&self.caps_bitmap,
&self.scope_hash,
&self.body_hash,
&self.nonce,
)
}

View file

@ -19,6 +19,7 @@ clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
kei-shared = { path = "../kei-shared" }
[dev-dependencies]
tempfile = "3"

View file

@ -1,82 +1,22 @@
//! DNA parser.
//! DNA parser — thin wrapper over `kei_shared::dna`.
//!
//! Format: `<role>::<caps>::<sha8-scope>::<sha8-body>-<hex8-nonce>`
//! Example: `edit-local::NG-FW-FD-CP-CG-TG-ND-RF::5435F821::AC73A6A3-e9bf468d`
//!
//! Wire-format SSoT lives in `kei_shared::dna`. This module re-exports
//! `ParsedDna` and exposes `split_dna` that maps `kei_shared::DnaError`
//! into `crate::Error::MalformedDna` so callers keep a single error type.
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedDna {
pub role: String,
pub caps: String,
pub scope_sha: String,
pub body_sha: String,
pub nonce: String,
}
pub use kei_shared::dna::ParsedDna;
/// Parse a DNA string into its five fields. Hex widths are validated.
/// Errors are wrapped in [`Error::MalformedDna`] with the raw DNA included
/// for debuggability, matching the pre-extraction contract.
pub fn split_dna(dna: &str) -> Result<ParsedDna> {
let parts: Vec<&str> = dna.split("::").collect();
if parts.len() != 4 {
return Err(Error::MalformedDna(format!(
"expected 4 '::'-segments, got {}: {}",
parts.len(),
dna
)));
}
let role = parts[0].to_string();
let caps = parts[1].to_string();
let scope_sha = parts[2].to_string();
let tail = parts[3];
if role.is_empty() {
return Err(Error::MalformedDna(format!("empty role: {}", dna)));
}
if caps.is_empty() {
return Err(Error::MalformedDna(format!("empty caps: {}", dna)));
}
if !is_hex8(&scope_sha) {
return Err(Error::MalformedDna(format!(
"scope_sha not 8 hex chars: {}",
scope_sha
)));
}
let tail_parts: Vec<&str> = tail.split('-').collect();
if tail_parts.len() != 2 {
return Err(Error::MalformedDna(format!(
"expected '<body_sha>-<nonce>' tail, got: {}",
tail
)));
}
let body_sha = tail_parts[0].to_string();
let nonce = tail_parts[1].to_string();
if !is_hex8(&body_sha) {
return Err(Error::MalformedDna(format!(
"body_sha not 8 hex chars: {}",
body_sha
)));
}
if !is_hex8(&nonce) {
return Err(Error::MalformedDna(format!(
"nonce not 8 hex chars: {}",
nonce
)));
}
Ok(ParsedDna {
role,
caps,
scope_sha,
body_sha,
nonce,
})
}
fn is_hex8(s: &str) -> bool {
s.len() == 8 && s.chars().all(|c| c.is_ascii_hexdigit())
kei_shared::dna::parse_dna(dna)
.map_err(|e| Error::MalformedDna(format!("{e}: {dna}")))
}
#[cfg(test)]

View file

@ -20,6 +20,7 @@ pub mod pk;
pub mod rank;
pub mod search;
pub mod update;
pub(crate) mod update_invariant;
pub mod validate;
/// Full list of supported verbs — SSoT for documentation + schema

View file

@ -1,14 +1,13 @@
//! `update` verb — partial update by id. Only keys that appear in
//! the input JSON and that are declared on the schema are written.
//!
//! Type discipline: when a key is present its JSON kind MUST match the
//! field kind. Mismatch → `VerbError::InvalidType` (no silent coercion).
//! UPDATE + FTS reindex run in a single transaction so a mid-flight
//! failure leaves neither the row nor the FTS entry in a torn state.
//! `update` verb — partial update by id. Only declared schema keys
//! that appear in the input JSON are written. Type mismatch →
//! `InvalidType` (no silent coercion). UPDATE + FTS reindex run in a
//! single transaction so a mid-flight failure leaves neither the row
//! nor the FTS entry in a torn state.
use crate::error::VerbError;
use crate::schema::{EntitySchema, FieldDef, FieldKind};
use crate::verbs::pk::{self, PkValue};
use crate::verbs::update_invariant as inv;
use crate::verbs::validate;
use chrono::Utc;
use rusqlite::{types::Value as SqlValue, Connection};
@ -52,9 +51,12 @@ fn update_tx(
obj: &serde_json::Map<String, Value>,
) -> Result<(), VerbError> {
let tx = conn.unchecked_transaction()?;
// Debug-build snapshot for the non-input-FTS-stable invariant
// asserted in `reindex_fts`. Release: empty map, no SELECT.
let pre_update = inv::pre_update_snapshot(&tx, schema, id);
exec_update_tx(&tx, schema, id, set_cols, values)?;
if let Some(cols) = schema.fts_columns {
reindex_fts(&tx, schema, cols, id, obj)?;
reindex_fts(&tx, schema, cols, id, obj, &pre_update)?;
}
tx.commit()?;
Ok(())
@ -67,15 +69,12 @@ fn exec_update_tx(
set_cols: &[&'static str],
values: Vec<SqlValue>,
) -> Result<(), VerbError> {
let placeholders: Vec<String> =
(1..=set_cols.len()).map(|i| format!("{} = ?{i}", set_cols[i - 1])).collect();
let placeholders: Vec<String> = (1..=set_cols.len())
.map(|i| format!("{} = ?{i}", set_cols[i - 1])).collect();
let id_idx = set_cols.len() + 1;
let sql = format!(
"UPDATE {} SET {} WHERE {}=?{}",
schema.table,
placeholders.join(", "),
schema.pk().name,
id_idx
schema.table, placeholders.join(", "), schema.pk().name, id_idx,
);
let mut all: Vec<SqlValue> = values;
all.push(id.as_sql());
@ -122,15 +121,23 @@ fn value_from_input(
Ok(Some(validate::coerce(f, raw)?))
}
/// Rebuild the FTS5 row after the primary UPDATE. INVARIANT: FTS
/// columns NOT in `input` keep their pre-UPDATE value through the
/// UPDATE (holds while UPDATE only touches `input` columns). Proof
/// and debug-build assertion in `verbs/update_invariant.rs`.
fn reindex_fts(
tx: &rusqlite::Transaction<'_>,
schema: &EntitySchema,
cols: &[&str],
id: &PkValue,
input: &serde_json::Map<String, Value>,
pre_update: &serde_json::Map<String, Value>,
) -> Result<(), VerbError> {
let table = schema.table;
let existing = read_existing_fts(tx, schema, cols, id)?;
#[cfg(debug_assertions)]
inv::debug_assert_non_input_fts_stable(cols, input, pre_update, &existing);
let _ = pre_update; // unused in release builds
tx.execute(
&format!("DELETE FROM fts_{table} WHERE {table}_id=?1"),
rusqlite::params![id.as_sql()],
@ -167,7 +174,7 @@ fn fts_row_values(
values
}
fn read_existing_fts(
pub(super) fn read_existing_fts(
tx: &rusqlite::Transaction<'_>,
schema: &EntitySchema,
cols: &[&str],

View file

@ -0,0 +1,65 @@
//! Debug-only invariant helpers for the `update` verb.
//!
//! Split out of `verbs/update.rs` so that file stays within the
//! Constructor-Pattern 200-LOC cap. The functions here encode the FTS
//! reindex contract: columns NOT present in an UPDATE's input JSON
//! must not change during the UPDATE.
//!
//! `cfg(debug_assertions)` gates both the snapshot SELECT and the
//! assertion itself — release builds compile this module down to a
//! no-op snapshot that returns an empty map.
use crate::schema::EntitySchema;
use crate::verbs::pk::PkValue;
use serde_json::Value;
/// Snapshot FTS columns BEFORE an UPDATE runs. Debug builds read the
/// row via `read_existing_fts`; release builds skip the read and
/// return an empty map.
#[cfg(debug_assertions)]
pub(super) fn pre_update_snapshot(
tx: &rusqlite::Transaction<'_>,
schema: &EntitySchema,
id: &PkValue,
) -> serde_json::Map<String, Value> {
let Some(cols) = schema.fts_columns else {
return serde_json::Map::new();
};
super::update::read_existing_fts(tx, schema, cols, id).unwrap_or_default()
}
#[cfg(not(debug_assertions))]
pub(super) fn pre_update_snapshot(
_tx: &rusqlite::Transaction<'_>,
_schema: &EntitySchema,
_id: &PkValue,
) -> serde_json::Map<String, Value> {
serde_json::Map::new()
}
/// Debug-only invariant check: every FTS column NOT present in `input`
/// must still hold its pre-UPDATE value after the UPDATE completes.
/// If this fires, a non-input column changed under the UPDATE (trigger,
/// computed column, etc.) and the `reindex_fts` contract is broken.
#[cfg(debug_assertions)]
pub(super) fn debug_assert_non_input_fts_stable(
cols: &[&str],
input: &serde_json::Map<String, Value>,
pre_update: &serde_json::Map<String, Value>,
existing: &serde_json::Map<String, Value>,
) {
for c in cols {
if input.contains_key(*c) {
continue;
}
let pre = pre_update.get(*c);
let post = existing.get(*c);
debug_assert_eq!(
pre, post,
"reindex_fts invariant violated: non-input FTS column `{c}` \
changed during UPDATE (pre: {pre:?}, post: {post:?}). A \
trigger or computed column likely modified it. Snapshot \
the row BEFORE the UPDATE instead of reading it after."
);
}
}

View file

@ -0,0 +1,364 @@
//! Residual audit regression tests (Wave 14, 2026-04-23).
//!
//! Each block names the residual it pins:
//! * A — ddl.rs panic-free across every FieldKind variant
//! * B — update.rs FTS reindex non-input-column invariant
//! * C — engine.rs WAL pragma fallback on a read-only FS
//! * D — search.rs has_searchable_token Unicode edge cases
//!
//! Scope: kei-entity-store only. No workspace / cross-crate changes.
use kei_entity_store::error::VerbError;
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind};
use kei_entity_store::verbs::{create, search, update};
use kei_entity_store::Store;
use serde_json::json;
// ---------- Residual A — ddl.rs never panics on any FieldKind ----------
/// Compile-time exhaustive match over `FieldKind`. If a new variant is
/// added but not listed here, this fails to compile — which is exactly
/// the reminder we want for the audit invariant "DDL covers every kind".
fn every_field_kind() -> Vec<FieldKind> {
// The `match` is unreachable at runtime — it exists purely to make
// the compiler enforce exhaustiveness when future variants are added.
fn exhaustive(k: FieldKind) -> FieldKind {
match k {
FieldKind::IntegerPk
| FieldKind::TextPk
| FieldKind::IntegerNotNull
| FieldKind::Integer
| FieldKind::TextNotNull
| FieldKind::Text
| FieldKind::TextDefault
| FieldKind::TextArchiveEnum
| FieldKind::Real
| FieldKind::RealDefault
| FieldKind::TimestampCreated
| FieldKind::TimestampUpdated => k,
}
}
vec![
FieldKind::IntegerPk,
FieldKind::TextPk,
FieldKind::IntegerNotNull,
FieldKind::Integer,
FieldKind::TextNotNull,
FieldKind::Text,
FieldKind::TextDefault,
FieldKind::TextArchiveEnum,
FieldKind::Real,
FieldKind::RealDefault,
FieldKind::TimestampCreated,
FieldKind::TimestampUpdated,
]
.into_iter()
.map(exhaustive)
.collect()
}
fn fields_for_kind(kind: FieldKind) -> &'static [FieldDef] {
match kind {
FieldKind::IntegerPk => Box::leak(Box::new([FieldDef::pk("id")])),
FieldKind::TextPk => Box::leak(Box::new([FieldDef::text_pk("id")])),
FieldKind::IntegerNotNull => {
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::integer_nn("n")]))
}
FieldKind::Integer => Box::leak(Box::new([FieldDef::pk("id"), FieldDef::integer("n")])),
FieldKind::TextNotNull => {
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::text_nn("t")]))
}
FieldKind::Text => Box::leak(Box::new([FieldDef::pk("id"), FieldDef::text("t")])),
FieldKind::TextDefault => Box::leak(Box::new([
FieldDef::pk("id"),
FieldDef::text_default("t", "d'efault"),
])),
FieldKind::TextArchiveEnum => Box::leak(Box::new([
FieldDef::pk("id"),
FieldDef::text_archive_enum("status", "active", "archived"),
])),
FieldKind::Real => Box::leak(Box::new([FieldDef::pk("id"), FieldDef::real("r")])),
FieldKind::RealDefault => Box::leak(Box::new([
FieldDef::pk("id"),
FieldDef::real_default("r", 3.14),
])),
FieldKind::TimestampCreated => {
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::created_at()]))
}
FieldKind::TimestampUpdated => {
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::updated_at()]))
}
}
}
fn probe_schema(fields: &'static [FieldDef]) -> EntitySchema {
EntitySchema {
name: "fk_probe",
table: "fk_probes",
fields,
enabled_verbs: &["create"],
fts_columns: None,
edge_table: None,
edge_key_kind: EdgeKeyKind::IntegerPair,
archived_field: None,
custom_migrations: &[],
}
}
#[test]
fn ddl_never_panics_on_any_fieldkind() {
// For every `FieldKind`, build a minimal schema and render its
// primary-table DDL. If any variant had a `panic!`/`unreachable!`
// path in `ddl::column()` this would trip.
for kind in every_field_kind() {
let schema = probe_schema(fields_for_kind(kind));
let sql = kei_entity_store::ddl::primary_table(&schema);
assert!(sql.contains("CREATE TABLE"), "kind={kind:?} → {sql}");
}
}
// ---------- Residual B — update.rs FTS invariant ----------
static FTS_FIELDS: &[FieldDef] = &[
FieldDef::pk("id"),
FieldDef::text_nn("title"),
FieldDef::text("description"),
FieldDef::created_at(),
FieldDef::updated_at(),
];
static FTS_SCHEMA: EntitySchema = EntitySchema {
name: "doc",
table: "docs",
fields: FTS_FIELDS,
enabled_verbs: &["create", "update", "search"],
fts_columns: Some(&["title", "description"]),
edge_table: None,
edge_key_kind: EdgeKeyKind::IntegerPair,
archived_field: None,
custom_migrations: &[],
};
/// Debug-only: if a future BEFORE UPDATE trigger mutates an FTS column
/// that is NOT present in the `update` input, the `reindex_fts`
/// non-input invariant would fire. This test plants such a trigger
/// and verifies the debug_assert trips (panics). Release builds skip
/// the assertion and would instead silently drift — this test is
/// therefore gated on `debug_assertions` only.
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "reindex_fts invariant violated")]
fn update_debug_assert_trips_when_trigger_mutates_non_input_fts_column() {
let s = Store::open_memory(&[&FTS_SCHEMA]).unwrap();
let id = create::run(
s.conn(),
&FTS_SCHEMA,
json!({ "title": "t0", "description": "d0" }),
)
.unwrap()["id"]
.as_i64()
.unwrap();
// Plant a trigger: whenever `title` is updated, silently rewrite
// `description` too. This breaks the "non-input FTS col stays put"
// contract that `reindex_fts` relies on.
s.conn()
.execute_batch(
"CREATE TRIGGER docs_mutate_desc \
BEFORE UPDATE OF title ON docs \
BEGIN \
UPDATE docs SET description = 'drifted' WHERE id = NEW.id; \
END;",
)
.unwrap();
// Update only `title`. The trigger silently mutates `description`.
// Debug build must panic inside `reindex_fts`; release build would
// silently re-index with the new (drifted) value.
let _ = update::run(s.conn(), &FTS_SCHEMA, json!({ "id": id, "title": "t1" }));
}
#[test]
fn update_partial_preserves_non_input_fts_column() {
// Update only `title`. The `description` FTS column must survive
// unchanged on both the entity row and the FTS index — this is the
// production counterpart to the debug-build non-input invariant.
let s = Store::open_memory(&[&FTS_SCHEMA]).unwrap();
let id = create::run(
s.conn(),
&FTS_SCHEMA,
json!({ "title": "first", "description": "keep this" }),
)
.unwrap()["id"]
.as_i64()
.unwrap();
update::run(s.conn(), &FTS_SCHEMA, json!({ "id": id, "title": "second" })).unwrap();
// Entity row: description unchanged.
let desc: String = s
.conn()
.query_row(
"SELECT description FROM docs WHERE id=?1",
rusqlite::params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(desc, "keep this");
// FTS index: description still searchable; title reflects UPDATE.
let hit_desc = search::run(s.conn(), &FTS_SCHEMA, json!({ "query": "keep" })).unwrap();
assert_eq!(hit_desc["results"].as_array().unwrap().len(), 1);
let hit_new = search::run(s.conn(), &FTS_SCHEMA, json!({ "query": "second" })).unwrap();
assert_eq!(hit_new["results"].as_array().unwrap().len(), 1);
let hit_old = search::run(s.conn(), &FTS_SCHEMA, json!({ "query": "first" })).unwrap();
assert_eq!(hit_old["results"].as_array().unwrap().len(), 0);
}
// ---------- Residual C — WAL pragma fallback ----------
#[cfg(unix)]
static WAL_MIN: EntitySchema = EntitySchema {
name: "m",
table: "m",
fields: &[FieldDef::pk("id"), FieldDef::text_nn("t"), FieldDef::created_at()],
enabled_verbs: &["create", "get"],
fts_columns: None,
edge_table: None,
edge_key_kind: EdgeKeyKind::IntegerPair,
archived_field: None,
custom_migrations: &[],
};
#[cfg(unix)]
fn chmod_dir(p: &std::path::Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(p).unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(p, perms).unwrap();
}
#[cfg(unix)]
#[test]
fn wal_pragma_fallback_keeps_store_usable() {
// Simulate "WAL unavailable" by pre-creating the DB normally
// (WAL succeeds), then making the PARENT directory read-only so
// re-opening the same file cannot create the WAL sidecar files.
// The pragma SHOULD fail but Store::open must still succeed and
// basic CRUD must work. Non-unix platforms skip — simulating a
// read-only dir portably is hard and adds little coverage.
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("wal.db");
// Happy-path open — WAL succeeds here, seeds a row.
{
let s = Store::open(&db, &[&WAL_MIN]).unwrap();
create::run(s.conn(), &WAL_MIN, json!({ "t": "pre" })).unwrap();
}
// Lock parent dir — WAL creation on reopen cannot succeed. On
// root-owned CI hosts chmod 0555 is a no-op and the assertion
// below still holds because we only check a pre-existing row.
chmod_dir(dir.path(), 0o555);
let result = Store::open(&db, &[&WAL_MIN]);
// Restore perms unconditionally so tempdir cleanup works.
chmod_dir(dir.path(), 0o755);
// If the platform's sqlite refuses to open at all in a RO dir
// (some builds require journal sidecar on every open), treat
// the test as inconclusive — the fallback code still ran.
if result.is_err() {
return;
}
let s = result.unwrap();
let row = kei_entity_store::verbs::get::run(s.conn(), &WAL_MIN, json!({ "id": 1 })).unwrap();
assert_eq!(row["t"], "pre");
}
// ---------- Residual D — search.rs Unicode punctuation edge cases ----------
static D_FIELDS: &[FieldDef] = &[
FieldDef::pk("id"),
FieldDef::text_nn("title"),
FieldDef::text("description"),
FieldDef::created_at(),
];
static D_SCHEMA: EntitySchema = EntitySchema {
name: "d",
table: "d_items",
fields: D_FIELDS,
enabled_verbs: &["create", "search"],
fts_columns: Some(&["title", "description"]),
edge_table: None,
edge_key_kind: EdgeKeyKind::IntegerPair,
archived_field: None,
custom_migrations: &[],
};
fn mk_d() -> Store { Store::open_memory(&[&D_SCHEMA]).unwrap() }
fn expect_invalid_input(err: VerbError) {
assert_eq!(err.exit_code(), 2, "must map to validation exit code");
match err {
VerbError::InvalidInput(_) => {}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn search_rejects_unicode_punctuation_only() {
// Unicode punctuation that is NOT ASCII: Spanish inverted marks,
// French guillemets, CJK punctuation, em-dashes. None of these are
// alphanumeric so `has_searchable_token` must reject the query.
let s = mk_d();
create::run(s.conn(), &D_SCHEMA, json!({ "title": "anything" })).unwrap();
for q in &["¿?¡!", "«»", "。、", "—–", "¡¿"] {
let err = search::run(s.conn(), &D_SCHEMA, json!({ "query": q })).unwrap_err();
expect_invalid_input(err);
}
}
#[test]
fn search_rejects_emoji_only_query() {
// Pure-emoji queries carry zero tokens for unicode61 — reject.
let s = mk_d();
create::run(s.conn(), &D_SCHEMA, json!({ "title": "anything" })).unwrap();
for q in &["\u{1F389}", "\u{1F525}\u{1F680}", "\u{2728}\u{1F440}"] {
let err = search::run(s.conn(), &D_SCHEMA, json!({ "query": q })).unwrap_err();
expect_invalid_input(err);
}
}
#[test]
fn search_rejects_zero_width_only() {
// Zero-width joiner (\u{200D}), zero-width space (\u{200B}),
// zero-width non-joiner (\u{200C}), BOM (\u{FEFF}) — all format
// characters, none alphanumeric. Query must be rejected, not
// silently matched as an empty phrase.
let s = mk_d();
create::run(s.conn(), &D_SCHEMA, json!({ "title": "anything" })).unwrap();
for q in &["\u{200B}", "\u{200C}\u{200D}", "\u{FEFF}\u{200B}"] {
let err = search::run(s.conn(), &D_SCHEMA, json!({ "query": q })).unwrap_err();
expect_invalid_input(err);
}
}
#[test]
fn search_accepts_mixed_rtl_query() {
// Arabic + Latin + punctuation. Arabic letters ARE alphanumeric
// by `char::is_alphanumeric`, so the gate must admit the query
// and let FTS5 tokenize it normally.
let s = mk_d();
create::run(
s.conn(),
&D_SCHEMA,
json!({ "title": "مرحبا world", "description": "greeting" }),
)
.unwrap();
// Latin side.
let v = search::run(s.conn(), &D_SCHEMA, json!({ "query": "world!" })).unwrap();
assert_eq!(v["results"].as_array().unwrap().len(), 1);
// Mixed RTL+Latin query is admitted (does not error).
let v = search::run(s.conn(), &D_SCHEMA, json!({ "query": "world مرحبا" })).unwrap();
// Don't over-assert on match count — porter/unicode61 tokenisation
// of Arabic is implementation-defined. We only verify the gate.
let _ = v;
}

96
_primitives/_rust/kei-shared/Cargo.lock generated Normal file
View file

@ -0,0 +1,96 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "kei-shared"
version = "0.1.0"
dependencies = [
"serde",
"thiserror",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

View file

@ -0,0 +1,14 @@
[package]
name = "kei-shared"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Shared substrate types — single source of truth for DNA format + small utility types"
[lib]
name = "kei_shared"
path = "src/lib.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
thiserror = "1"

View file

@ -0,0 +1,124 @@
//! DNA wire format: `<role>::<caps>::<sha8-scope>::<sha8-body>-<hex8-nonce>`.
//!
//! SSoT for the substrate identity string. Any format-level change lands
//! here and propagates to consumers (kei-agent-runtime, kei-dna-index)
//! through re-export, not duplication.
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Parsed DNA fields. Widths on hash segments are validated by `parse_dna`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedDna {
pub role: String,
pub caps: String,
pub scope_sha: String,
pub body_sha: String,
pub nonce: String,
}
/// Strict parse errors. Consumers that need looser semantics (e.g. legacy
/// 4-hex rolling-upgrade acceptance in kei-agent-runtime) keep their own
/// parser and error type.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DnaError {
#[error("DNA empty")]
Empty,
#[error("missing segments (expected 4 '::' separators, got {0})")]
MissingSegments(usize),
#[error("missing '-' between body and nonce")]
MissingNonceDelim,
#[error("empty role segment")]
EmptyRole,
#[error("empty caps segment")]
EmptyCaps,
#[error("invalid hex8 width for {field} (got {got})")]
HexWidth { field: &'static str, got: usize },
#[error("non-hex character in {field}")]
NonHex { field: &'static str },
}
/// `true` iff `s` is exactly 8 ASCII hex characters.
pub fn is_hex8(s: &str) -> bool {
s.len() == 8 && s.chars().all(|c| c.is_ascii_hexdigit())
}
/// Strict parse. Requires 4 `::` segments, `<body>-<nonce>` tail, and
/// 8-hex width on `scope_sha`, `body_sha`, `nonce`. Rejects empty role/caps.
pub fn parse_dna(s: &str) -> Result<ParsedDna, DnaError> {
if s.is_empty() {
return Err(DnaError::Empty);
}
let parts: Vec<&str> = s.split("::").collect();
if parts.len() != 4 {
return Err(DnaError::MissingSegments(parts.len()));
}
let (body_sha, nonce) = parts[3]
.split_once('-')
.ok_or(DnaError::MissingNonceDelim)?;
check_non_empty(parts[0], parts[1])?;
check_hex8("scope_sha", parts[2])?;
check_hex8("body_sha", body_sha)?;
check_hex8("nonce", nonce)?;
Ok(ParsedDna {
role: parts[0].to_string(),
caps: parts[1].to_string(),
scope_sha: parts[2].to_string(),
body_sha: body_sha.to_string(),
nonce: nonce.to_string(),
})
}
/// Render the canonical wire format. Deterministic — no I/O, no randomness.
pub fn compose_dna(
role: &str,
caps: &str,
scope_sha: &str,
body_sha: &str,
nonce: &str,
) -> String {
format!("{role}::{caps}::{scope_sha}::{body_sha}-{nonce}")
}
fn check_non_empty(role: &str, caps: &str) -> Result<(), DnaError> {
if role.is_empty() {
return Err(DnaError::EmptyRole);
}
if caps.is_empty() {
return Err(DnaError::EmptyCaps);
}
Ok(())
}
fn check_hex8(field: &'static str, value: &str) -> Result<(), DnaError> {
if value.len() != 8 {
return Err(DnaError::HexWidth {
field,
got: value.len(),
});
}
if !value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(DnaError::NonHex { field });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compose_matches_manual_format() {
let s = compose_dna("r", "C", "12345678", "ABCDEF01", "deadbeef");
assert_eq!(s, "r::C::12345678::ABCDEF01-deadbeef");
}
#[test]
fn is_hex8_basic() {
assert!(is_hex8("12345678"));
assert!(is_hex8("AbCdEf01"));
assert!(!is_hex8("1234567"));
assert!(!is_hex8("123456789"));
assert!(!is_hex8("1234567Z"));
}
}

View file

@ -0,0 +1,12 @@
//! kei-shared — shared substrate types.
//!
//! Single source of truth for the agent DNA wire format. Consumers
//! (kei-agent-runtime, kei-dna-index) import from here so a format
//! change is a one-file edit, not a two-crate refactor.
//!
//! Constructor Pattern: one file = one responsibility. `dna.rs` owns the
//! parse/compose/validate primitives, nothing else.
pub mod dna;
pub use dna::{compose_dna, is_hex8, parse_dna, DnaError, ParsedDna};

View file

@ -0,0 +1,86 @@
//! Smoke tests for the shared DNA parser.
use kei_shared::dna::{compose_dna, is_hex8, parse_dna, DnaError};
const CANONICAL: &str =
"edit-local::NG-FW-FD-CP-CG-TG-ND-RF::5435F821::AC73A6A3-e9bf468d";
#[test]
fn parse_valid_round_trip() {
let parsed = parse_dna(CANONICAL).expect("canonical DNA must parse");
assert_eq!(parsed.role, "edit-local");
assert_eq!(parsed.caps, "NG-FW-FD-CP-CG-TG-ND-RF");
assert_eq!(parsed.scope_sha, "5435F821");
assert_eq!(parsed.body_sha, "AC73A6A3");
assert_eq!(parsed.nonce, "e9bf468d");
}
#[test]
fn parse_empty_rejected() {
assert_eq!(parse_dna("").unwrap_err(), DnaError::Empty);
}
#[test]
fn parse_missing_segments() {
let err = parse_dna("only::two::segments").unwrap_err();
assert!(matches!(err, DnaError::MissingSegments(_)));
}
#[test]
fn parse_missing_nonce_delim() {
// 4 `::` segments but no '-' in tail.
let err = parse_dna("r::C::12345678::AC73A6A3e9bf468d").unwrap_err();
assert_eq!(err, DnaError::MissingNonceDelim);
}
#[test]
fn parse_non_hex_rejected() {
let err = parse_dna("r::C::12345678::AC73A6A3-ZZZZZZZZ").unwrap_err();
assert!(matches!(err, DnaError::NonHex { .. }));
}
#[test]
fn parse_short_hex_rejected() {
// scope_sha has only 4 hex chars — strict parser rejects.
let err = parse_dna("r::C::1234::AC73A6A3-e9bf468d").unwrap_err();
assert!(matches!(err, DnaError::HexWidth { .. }));
}
#[test]
fn parse_rejects_empty_role() {
let err = parse_dna("::C::12345678::AC73A6A3-e9bf468d").unwrap_err();
assert_eq!(err, DnaError::EmptyRole);
}
#[test]
fn parse_rejects_empty_caps() {
let err = parse_dna("r::::12345678::AC73A6A3-e9bf468d").unwrap_err();
assert_eq!(err, DnaError::EmptyCaps);
}
#[test]
fn compose_parse_round_trip() {
let composed = compose_dna(
"edit-local",
"NG-FW-FD-CP-CG-TG-ND-RF",
"5435F821",
"AC73A6A3",
"e9bf468d",
);
assert_eq!(composed, CANONICAL);
let parsed = parse_dna(&composed).expect("round-trip parse");
assert_eq!(parsed.scope_sha, "5435F821");
assert_eq!(parsed.body_sha, "AC73A6A3");
assert_eq!(parsed.nonce, "e9bf468d");
}
#[test]
fn is_hex8_accepts_valid_rejects_invalid() {
assert!(is_hex8("00000000"));
assert!(is_hex8("DeAdBeEf"));
assert!(is_hex8("abcdef01"));
assert!(!is_hex8("abcdefg1"), "non-hex 'g' must be rejected");
assert!(!is_hex8(""));
assert!(!is_hex8("1234567"));
assert!(!is_hex8("123456789"));
}

View file

@ -9,7 +9,7 @@ Complete install guide. Quick-start lives in the main [README](../README.md#inst
| Path | Command | Best for |
|---|---|---|
| **Plugin** (v0.16+, recommended on Claude Code 2.1+) | `/plugin marketplace add KeiSei84/KeiSeiKit` then `/plugin install keisei@keisei-marketplace` | Agents + skills + hooks + MCP. Zero cargo build. See [PLUGIN.md](../PLUGIN.md). |
| **Classic** `./install.sh` | Below | Full kit incl. 36 Rust primitives + 13 shell primitives. Required for `ops` / `dev` / `full` profiles. |
| **Classic** `./install.sh` | Below | Full kit incl. 47 Rust primitives + 13 shell primitives. Required for `ops` / `dev` / `full` profiles. |
## Prerequisites
@ -70,7 +70,7 @@ By default `./install.sh` is **minimal** — agents + hooks + skills + bridges,
| `frontend` | 8 site tools: `mock-render`, `visual-diff`, `tokens-sync`, `design-scrape`, `live-preview`, `figma-tokens`, `frontend-inspect`, `screenshot-decode` | ~60s | ~80 MB |
| `ops` | 9 infra tools: `kei-ledger`, `ssh-check`, `firewall-diff`, `provision-hetzner`, `provision-vultr`, `harden-base`, `metrics-scrape`, `log-ship`, `kei-provision` | ~90s | ~55 MB |
| `dev` | 17 dev tools: `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`, `kei-agent-runtime`, `kei-capability`, `kei-entity-store`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay` | ~90s | ~60 MB |
| `full` | everything in `MANIFEST.toml` `full` profile (46 primitives — see manifest for exact list; `kei-atom-discovery`, `kei-forge`, `kei-runtime` ship as sources only, not in any profile yet) | ~6 min | ~220 MB |
| `full` | everything in `MANIFEST.toml` `full` profile (46 primitives — see manifest for exact list; the v0.29 → v0.33 additions `kei-diff`, `kei-scheduler`, `kei-watch`, `kei-prune`, `kei-discover`, `kei-brain-view`, `kei-hibernate`, `kei-ledger-sign`, `kei-dna-index`, `kei-fork`, `kei-shared` ship as sources only, not in any profile yet) | ~6 min | ~220 MB |
Examples:
@ -207,7 +207,7 @@ Interactive wizard: run `/hooks-control` — click-only picker that shows curren
| Generic agents (manifests) | 12 | `kei-code-implementer`, `kei-critic`, `kei-validator`, `kei-security-auditor`, `kei-architect`, `kei-researcher`, `kei-ml-implementer`, `kei-cost-guardian`, `kei-modal-runner`, ... |
| Hooks (PreToolUse / PostToolUse) | 12 | `assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`, `agent-fork-logger`, `orchestrator-dirty-check`, `site-wysiwyd-check`, `session-end-dump`, `milestone-commit-hook`, `error-spike-detector`, `agent-capability-check`, `agent-capability-verify` |
| Portable skills | 43 | `compose-solution`, `new-agent`, `new-project`, `site-create`, `schema-design`, `observability-setup`, `auth-setup`, `api-design`, `ci-scaffold`, `test-matrix`, `docs-scaffold`, `vm-provision`, ... |
| Primitives (Rust crates, opt-in) | 36 | `kei-ledger`, `kei-migrate`, `kei-changelog`, `ssh-check`, `firewall-diff`, `mock-render`, `visual-diff`, `tokens-sync`, `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`, `keisei`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-entity-store`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime` |
| Primitives (Rust crates, opt-in) | 47 | `kei-ledger`, `kei-migrate`, `kei-changelog`, `ssh-check`, `firewall-diff`, `mock-render`, `visual-diff`, `tokens-sync`, `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`, `keisei`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-entity-store`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime`, `kei-diff`, `kei-scheduler`, `kei-watch`, `kei-prune`, `kei-discover`, `kei-brain-view`, `kei-hibernate`, `kei-ledger-sign`, `kei-dna-index`, `kei-fork`, `kei-shared` |
| Primitives (shell, opt-in via profile) | 13 | `tomd`, `design-scrape`, `live-preview`, `figma-tokens`, `frontend-inspect`, `screenshot-decode`, `metrics-scrape`, `log-ship`, `provision-hetzner`, `provision-vultr`, `harden-base`, `kei-ci-lint`, `kei-docs-scaffold` |
| Shell helpers (always copied) | 3 | `kei-sleep-setup`, `kei-sleep-sync`, `kei-sleep-queue` (dormant until you run `/sleep-setup`) |
| Cross-tool bridges | 11 | Cursor legacy/MDC, Codex, Copilot, Windsurf, Junie, Continue, Gemini, Aider, Replit |

View file

@ -8,9 +8,9 @@ Every shipped component, its real behaviour, and where to look in source. Each s
## Rust primitives
All 36 crates live under `_primitives/_rust/<name>/`. After `install.sh` runs, binaries land at `~/.claude/agents/_primitives/_rust/target/release/<name>`. Exit codes: `0` success, `1` usage/IO error, `2` validation/diff-found (per-tool; see each entry).
All 47 crates live under `_primitives/_rust/<name>/`. After `install.sh` runs, binaries land at `~/.claude/agents/_primitives/_rust/target/release/<name>`. Exit codes: `0` success, `1` usage/IO error, `2` validation/diff-found (per-tool; see each entry).
> NOTE (v0.27): the following sections enumerate the 25 crates documented through v0.22. 11 newer crates shipped in v0.23v0.27 (`kei-entity-store`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime`) are not yet documented here — see `CHANGELOG.md` Unreleased block and the crates' own `Cargo.toml` + `atoms/*.md` for their current CLI surface. A full REFERENCE.md expansion is tracked as a follow-up doc task.
> NOTE (v0.33): the following sections enumerate the 25 crates documented through v0.22. 22 newer crates shipped in v0.23v0.33 (`kei-entity-store`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime`, `kei-diff`, `kei-scheduler`, `kei-watch`, `kei-prune`, `kei-discover`, `kei-brain-view`, `kei-hibernate`, `kei-ledger-sign`, `kei-dna-index`, `kei-fork`, `kei-shared`) are not yet documented here — see `CHANGELOG.md` for their shipped semantics and the crates' own `Cargo.toml` + `atoms/*.md` for their current CLI surface. A full REFERENCE.md expansion is tracked as a follow-up doc task.
### `kei-ledger` — agent-fork lifecycle ledger (RULE 0.12)

View file

@ -16,7 +16,7 @@ An **atom** is **one verb** (one operation) on a primitive, not one crate. Examp
- Discoverable (aggregated into `capabilities.toml`)
- Composable (runtime pipes atoms by schema compatibility)
**Granularity target:** ~150 atoms across the current 36 crates (was 25 at v0.22 lock; 11 crates added v0.23v0.27). Crate = physical container; atom = unit of composition.
**Granularity target:** ~150 atoms across the current 47 crates (was 25 at v0.22 lock; 22 crates added v0.23v0.33). Crate = physical container; atom = unit of composition.
---
@ -340,7 +340,7 @@ Here is exactly what each parallel stream can assume from this schema:
- **Does NOT depend on:** Atoms-refactor (can work against any single atom template), Graph (independent), Runtime (independent)
### Stream B — Atoms refactor
- **Reads:** current 36 crates (25 at v0.22 lock; 11 added v0.23v0.27)
- **Reads:** current 47 crates (25 at v0.22 lock; 22 added v0.23v0.33)
- **Writes:** `atoms/<verb>.md` + `atoms/schemas/*.json` + splits `src/main.rs``src/atoms/*.rs`, adds `[package.metadata.keisei]` to each `Cargo.toml`
- **Does NOT depend on:** UI (can progress independently), Graph, Runtime. No build.rs, no generated files — atoms/*.md is SSoT.