feat(v0.16.1): dynamic schema SSoT + KNOWN_SCHEMAS drift-test + mode-matrix Phase 3.6
Three polish items from post-audit parallel agent.
1. Dynamic schema whitelist (drops hardcoded const drift)
_assembler/src/schemas_export.rs (NEW, 136 LOC) — loader cube,
priority path $AGENT_ROOT/artifacts/schemas.json →
~/.claude/agents/artifacts/schemas.json → BUILTIN fallback.
Hand-rolled JSON parser (no serde_json dep).
_assembler/src/validator.rs delegates to schemas_export::load,
keeps KNOWN_ARTIFACT_SCHEMAS alias for back-compat.
_primitives/_rust/kei-artifact/src/export.rs (NEW, 82 LOC) —
write() + render() + default_path().
_primitives/_rust/kei-artifact/src/cli_cmds.rs (NEW, 126 LOC) —
extracted cmd_emit/get/list/chain so main stays <200 LOC.
ExportSchemas + ListSchemas subcommands; cmd_register
auto-refreshes export file (best-effort).
2. KNOWN_SCHEMAS SSoT — documented-dual-const + drift-test
(Option "simpler than new crate"). SSoT in kei-artifact's
BUILTIN; schemas_export::BUILTIN is a documented mirror;
builtin_schemas_do_not_drift_from_kei_artifact test in
validator.rs parses the primitive's source at test time and
diffs. <30 LOC change. No workspace structural change —
assembler stays decoupled from runtime primitive.
3. Agent-to-mode matrix + wizard Phase 3.6
_blocks/mode-matrix.md (NEW, 24 LOC) — 11-row table mapping
agent role × recommended mode blocks.
skills/new-agent/SKILL.md — new Phase 3.6 (between name-confirm
3.5 and manifest-write 4). AskUserQuestion with 5
cognitive-mode options (skeptic/devils-advocate/minimalist/
maximalist/first-principles, multiSelect). Appends picked
labels to manifest's blocks array. Defaults to NONE.
_blocks/README.md adds one-line reference to the matrix.
_assembler/tests/mode_blocks.rs (NEW, 78 LOC) — 3 integration
tests lock the wiring.
README.md — all accumulated count + pre-built-binaries + plugin
section edits from the v0.16 cycle consolidated here (will be
replaced by markers in v0.17 counts-autogen refactor).
Tests: assembler 24 → 33 (+9), kei-artifact 24 → 31 (+7), total
48 → 64. cargo check --workspace clean.
Constructor Pattern: largest new file validator.rs 180 LOC.
Pre-existing flagged for separate refactor: kei-artifact
validate.rs 268 LOC (not touched by this polish).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b62b219500
commit
d95a3ba48c
13 changed files with 655 additions and 124 deletions
85
README.md
85
README.md
|
|
@ -28,7 +28,7 @@ Thanks.
|
|||
|
||||
## What it is
|
||||
|
||||
KeiSeiKit is a comprehensive drop-in toolkit for [Claude Code](https://claude.com/claude-code). It ships a curated set of composable behavioral blocks, a Rust assembler that builds agent `.md` files from TOML manifests deterministically, 9 pre-wired PreToolUse/PostToolUse hooks (three of them dedicated to RULE 0.14 session self-audit), 38 portable skills (including an interactive `/new-agent` wizard, 10 hub-and-spoke pipelines, and the `/self-audit` retrospective skill), **23 Rust primitive crates**, 13 opt-in shell primitives (plus 3 always-copied sleep-sync helpers), and 11 cross-tool bridge templates. Everything follows a Constructor Pattern: one file per concern, manifests as single source of truth, and the generated agent files are regenerated on every relevant edit.
|
||||
KeiSeiKit is a comprehensive drop-in toolkit for [Claude Code](https://claude.com/claude-code). It ships a curated set of composable behavioral blocks, a Rust assembler that builds agent `.md` files from TOML manifests deterministically, 9 pre-wired PreToolUse/PostToolUse hooks (three of them dedicated to RULE 0.14 session self-audit), 39 portable skills (including an interactive `/new-agent` wizard, 10 hub-and-spoke pipelines, and the `/self-audit` retrospective skill), **24 Rust primitive crates**, 13 opt-in shell primitives (plus 3 always-copied sleep-sync helpers), and 11 cross-tool bridge templates. Everything follows a Constructor Pattern: one file per concern, manifests as single source of truth, and the generated agent files are regenerated on every relevant edit.
|
||||
|
||||
The kit is MIT-licensed and fully generic — install it on a fresh machine and you get a sane 12-agent fleet (implementers, critics, researchers, cost-guardians, and more — all namespaced under `kei-*` so they won't collide with your own same-named agents), a wizard for spinning up new project specialists, 10 pipeline skills that combine primitives end-to-end (`/compose-solution`, `/site-create`, `/schema-design`, `/observability-setup`, `/auth-setup`, `/api-design`, `/ci-scaffold`, `/test-matrix`, `/docs-scaffold`, `/new-project`, `/vm-provision`), and a build pipeline that keeps every agent derivable from its manifest.
|
||||
|
||||
|
|
@ -50,7 +50,33 @@ The kit is MIT-licensed and fully generic — install it on a fresh machine and
|
|||
|
||||
`install.sh` checks only the deps relevant to the selected profile and soft-warns once per missing tool.
|
||||
|
||||
## Install
|
||||
## Plugin install (v0.16+, recommended)
|
||||
|
||||
If you have Claude Code 2026.04+ (plugin format supported), install via the Claude Code plugin system:
|
||||
|
||||
```bash
|
||||
# 1. Add this marketplace (one-time)
|
||||
/plugin marketplace add KeiSei84/KeiSeiKit
|
||||
|
||||
# 2. Install the plugin
|
||||
/plugin install keisei@keisei-marketplace
|
||||
|
||||
# Later: update or uninstall
|
||||
/plugin update keisei
|
||||
/plugin uninstall keisei@keisei-marketplace
|
||||
```
|
||||
|
||||
The plugin layout matches the Anthropic plugin spec — all agents, skills, hooks, and the MCP server are registered automatically. No need to touch `~/.claude/settings.json` or run `install.sh`.
|
||||
|
||||
**What the plugin registers:** agents in `agents/`, skills in `skills/*/SKILL.md`, hooks via `hooks/hooks.json`, MCP server via `.mcp.json`. Everything is sandboxed to the plugin install location via `${CLAUDE_PLUGIN_ROOT}` — no pollution of your personal `~/.claude/` config.
|
||||
|
||||
**What the plugin does NOT install automatically:** the 24 Rust primitive crates. Those need `cargo` and are built per-profile via the classic installer below. If you want the Rust primitives (tomd, kei-ledger, provision-hetzner, etc.), fall back to the classic install path. A future plugin version may ship pre-built release binaries for common platforms.
|
||||
|
||||
See [`PLUGIN.md`](./PLUGIN.md) for the full plugin layout, prerequisites, and known limitations.
|
||||
|
||||
## Classic install (git clone + install.sh, still fully supported)
|
||||
|
||||
For power users who want the Rust primitives, full control over the profile, or who don't have plugin-capable Claude Code yet:
|
||||
|
||||
```bash
|
||||
git clone <your-fork-of-this-repo> KeiSeiKit
|
||||
|
|
@ -58,6 +84,36 @@ cd KeiSeiKit
|
|||
./install.sh # profile=minimal (default, no primitives)
|
||||
```
|
||||
|
||||
### Pre-built binaries (no Rust toolchain needed)
|
||||
|
||||
From v0.16.0 on, every tagged release attaches pre-built Rust binaries,
|
||||
so you can skip the local `cargo build --release` step entirely.
|
||||
Download the archive for your platform from
|
||||
[Releases](https://github.com/KeiSei84/KeiSeiKit/releases) and extract
|
||||
it into the primitives workspace **before** running `./install.sh`:
|
||||
|
||||
- `keisei-x86_64-unknown-linux-gnu.tar.gz` — Linux x86_64
|
||||
- `keisei-aarch64-unknown-linux-gnu.tar.gz` — Linux ARM64 (best-effort)
|
||||
- `keisei-x86_64-apple-darwin.tar.gz` — macOS Intel
|
||||
- `keisei-aarch64-apple-darwin.tar.gz` — macOS Apple Silicon
|
||||
|
||||
```bash
|
||||
# after cloning the kit:
|
||||
mkdir -p _primitives/_rust/target/release
|
||||
tar xzf keisei-<your-target>.tar.gz -C _primitives/_rust/target/release
|
||||
export KEI_SKIP_RUST_BUILD=1 # optional — forces skip
|
||||
./install.sh --profile=full
|
||||
```
|
||||
|
||||
`install.sh` auto-detects pre-built binaries in
|
||||
`_primitives/_rust/target/release/` and skips the primitives
|
||||
`cargo build --workspace --release` when they are present. Set
|
||||
`KEI_SKIP_RUST_BUILD=1` to force-skip regardless of detection (useful on
|
||||
air-gapped machines). Each release also ships a `*.sha256` checksum
|
||||
file — verify with `sha256sum -c keisei-<target>.tar.gz.sha256` before
|
||||
extracting. Release notes are auto-generated from the git history by
|
||||
`kei-changelog` — see [`CHANGELOG.md`](./CHANGELOG.md) for the full log.
|
||||
|
||||
`install.sh` is idempotent. It:
|
||||
|
||||
1. Creates `~/.claude/agents/{_blocks,_manifests,_primitives,_bridges,_templates,_assembler,_generated}`, `~/.claude/hooks`, `~/.claude/skills`
|
||||
|
|
@ -65,9 +121,9 @@ cd KeiSeiKit
|
|||
3. Copies primitives ONLY for the selected profile (default: `minimal` = none). Tracks installed set in `~/.claude/agents/_primitives/.installed`.
|
||||
4. Copies generic manifests (skips if you already have a manifest with that name)
|
||||
5. Builds the Rust assembler (`cargo build --release` in `_assembler/`)
|
||||
6. If any Rust primitive is in the selected profile: writes a scoped workspace `Cargo.toml` listing ONLY the installed crates, then `cargo build --release`
|
||||
6. If any Rust primitive is in the selected profile: writes a scoped workspace `Cargo.toml` listing ONLY the installed crates, then `cargo build --release` — **skipped when `KEI_SKIP_RUST_BUILD=1` or pre-built binaries are detected in `target/release/`**
|
||||
7. Generates agent `.md` files in-place with `AGENT_ROOT=~/.claude/agents assemble --in-place`
|
||||
8. Copies the 10 hooks and 38 skills
|
||||
8. Copies the 9 hooks and 39 skills
|
||||
|
||||
After install, the only remaining step is merging `settings-snippet.json` into your `~/.claude/settings.json` to activate the hooks. You can do this automatically with `./install.sh --activate-hooks` or answer `y` at the end-of-install TTY prompt.
|
||||
|
||||
|
|
@ -91,7 +147,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` | 8 infra tools: `kei-ledger`, `ssh-check`, `firewall-diff`, `provision-hetzner`, `provision-vultr`, `harden-base`, `metrics-scrape`, `log-ship` | ~90s | ~50 MB |
|
||||
| `dev` | 4 dev tools: `kei-migrate`, `kei-changelog`, `kei-ci-lint`, `kei-docs-scaffold` | ~60s | ~40 MB |
|
||||
| `full` | everything (36 primitives) | ~5 min | ~200 MB |
|
||||
| `full` | everything (37 primitives) | ~5 min | ~200 MB |
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -108,22 +164,22 @@ Examples:
|
|||
|
||||
Profile resolution lives in `_primitives/MANIFEST.toml` — one `[primitive.<name>]` entry per primitive plus a `[profile]` block. Edit the manifest to define new profiles without touching `install.sh`.
|
||||
|
||||
> **Migrating from a full install:** if you're re-running `install.sh` after an earlier version that installed all primitives unconditionally, the new default (`minimal`) will REMOVE them. To preserve the old behaviour explicitly, pass `--profile=full` (currently 36 primitives).
|
||||
> **Migrating from a full install:** if you're re-running `install.sh` after an earlier version that installed all primitives unconditionally, the new default (`minimal`) will REMOVE them. To preserve the old behaviour explicitly, pass `--profile=full` (currently 37 primitives).
|
||||
|
||||
> **Re-install disclaimer:** `install.sh` is idempotent for clean state but **overwrites kit-owned `_blocks/`, `_primitives/`, `_bridges/`, `_templates/`, `_assembler/`, `hooks/`, and `skills/` on re-run** — local modifications under those directories are backed up to `<dir>.bak-TIMESTAMP/` (or, for shared hook files, to `<file>.bak-TIMESTAMP`). User-owned `_manifests/*.toml` are never overwritten.
|
||||
|
||||
## Runtime hook controls
|
||||
|
||||
Every kit-shipped hook (v0.14.2+) honours two env vars so you can silence noise or isolate a failure without editing `~/.claude/settings.json`:
|
||||
Every kit-shipped hook (v0.15.1+) honours two env vars so you can silence noise or isolate a failure without editing `~/.claude/settings.json`:
|
||||
|
||||
- `KEI_DISABLED_HOOKS` — comma- or space-list of hook base names (no `.sh`), e.g. `KEI_DISABLED_HOOKS=site-wysiwyd-check,milestone-commit-hook`. The literal `all` disables every hook.
|
||||
- `KEI_DISABLED_HOOKS` — comma- or space-list of hook base names (no `.sh`), e.g. `KEI_DISABLED_HOOKS=site-wysiwyd-check,milestone-commit-hook`. Matching is **tokenized exact-match** (v0.15.1 fix — prior versions used substring-glob which let any value containing `all` disable every hook). The literal `all` token still disables every hook.
|
||||
- `KEI_HOOK_PROFILE` — one of `full` (default), `advisory-off`, `minimal`, `off`.
|
||||
|
||||
| Profile | What stays on |
|
||||
|---|---|
|
||||
| `full` (default) | Every hook |
|
||||
| `advisory-off` | Disables pure-stderr advisories (`recurrence-suggest`, `citation-verify`, `error-spike-detector`, `milestone-commit-hook`). Safety gates stay on. |
|
||||
| `minimal` | Only safety-critical: `no-github-push`, `genesis-leak-guard`, `no-hand-edit-agents`, `secrets-guard`, `assemble-validate`, `git-pre-commit-genesis`. Everything else off. |
|
||||
| `minimal` | Only the four kit-shipped hooks that are either structural integrity (`no-hand-edit-agents`, `assemble-validate`) or observability-critical under RULE 0.12 / RULE 0.14 (`agent-fork-logger`, `session-end-dump`). Every other kit hook is off. User-global safety hooks such as `no-github-push`, `secrets-guard`, `genesis-leak-guard`, or `git-pre-commit-genesis` are not shipped by the kit but are respected when they are present in `~/.claude/hooks/`. |
|
||||
| `off` | Every hook off — escape hatch for debugging hook interactions. |
|
||||
|
||||
```bash
|
||||
|
|
@ -143,16 +199,16 @@ Interactive wizard: run `/hooks-control` — click-only picker that shows curren
|
|||
|
||||
| Category | Count | Examples |
|
||||
|---|---:|---|
|
||||
| Behavioral blocks | 73 | `baseline`, `evidence-grading`, `rule-math-first`, `stack-rust-axum`, `stack-react-vite`, `stack-sveltekit`, `stack-astro`, `deploy-modal`, `api-fal-ai`, ... |
|
||||
| Behavioral blocks | 77 | `baseline`, `evidence-grading`, `rule-math-first`, `stack-rust-axum`, `stack-react-vite`, `stack-sveltekit`, `stack-astro`, `deploy-modal`, `api-fal-ai`, ... |
|
||||
| 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) | 9 | `assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`, `agent-fork-logger`, `site-wysiwyd-check`, `session-end-dump`, `milestone-commit-hook`, `error-spike-detector` |
|
||||
| Portable skills | 38 | `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) | 23 | `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` |
|
||||
| Portable skills | 39 | `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) | 24 | `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` |
|
||||
| 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 |
|
||||
|
||||
Of the 73 blocks, the **8 base blocks** (`baseline`, `evidence-grading`, `memory-protocol`, `rule-pre-dev-gate`, `rule-test-first`, `rule-error-budget`, `rule-double-audit`, `rule-math-first`) are referenced directly by the 12 shipped manifests. The remaining blocks (`stack-*`, `deploy-*`, `api-*`, `scraper-*`, `domain-*`) are a library consumed by the `/new-agent` wizard and the hub-and-spoke pipeline skills: when you compose a project specialist or spin up a site, the wizard / pipeline picks the appropriate blocks and emits artefacts that reference them.
|
||||
Of the 77 blocks, the **8 base blocks** (`baseline`, `evidence-grading`, `memory-protocol`, `rule-pre-dev-gate`, `rule-test-first`, `rule-error-budget`, `rule-double-audit`, `rule-math-first`) are referenced directly by the 12 shipped manifests. The remaining blocks (`stack-*`, `deploy-*`, `api-*`, `scraper-*`, `domain-*`) are a library consumed by the `/new-agent` wizard and the hub-and-spoke pipeline skills: when you compose a project specialist or spin up a site, the wizard / pipeline picks the appropriate blocks and emits artefacts that reference them.
|
||||
|
||||
**Cognitive mode blocks** (`_blocks/mode-*.md`) are composable behavioural skews — `mode-skeptic`, `mode-devils-advocate`, `mode-minimalist`, `mode-maximalist`, `mode-first-principles`. Add any combination to an agent's manifest `blocks = [...]` list to stack the mode. Modes compose: `mode-skeptic` + `mode-minimalist` gives you an adversarial pruner; `mode-devils-advocate` + `mode-first-principles` gives a constraint-driven steel-manner. See `_blocks/README.md` for the full list.
|
||||
|
||||
|
|
@ -283,7 +339,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`,
|
|||
|
||||
## Primitives (Rust)
|
||||
|
||||
`_primitives/_rust/` is a Cargo workspace with 23 single-binary crates (v0.13.0 added 4 deep-sleep primitives; v0.14.0 added 10 LBM-port MCP crates; v0.14.2 removed `genesis-scan` — internal-only tool, not shipped publicly). `install.sh` builds `--release` for the subset selected by the active profile and drops binaries at `~/.claude/agents/_primitives/_rust/target/release/<name>`.
|
||||
`_primitives/_rust/` is a Cargo workspace with 24 single-binary crates (v0.13.0 added 4 deep-sleep primitives; v0.14.0 added 10 LBM-port MCP crates; v0.14.2 removed `genesis-scan` — internal-only tool, not shipped publicly; v0.15.0 added `kei-artifact` for typed review-handoff). `install.sh` builds `--release` for the subset selected by the active profile and drops binaries at `~/.claude/agents/_primitives/_rust/target/release/<name>`.
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
|
|
@ -300,6 +356,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`,
|
|||
| `kei-refactor-engine` | v0.13.0 — consumes `kei-conflict-scan` JSON; emits plan markdown + auto-resolve review markdown (NOT a unified diff; v0.14.1 retraction) |
|
||||
| `kei-graph-check` | v0.13.0 — post-refactor wikilink + handoff + block-ref resolver gate |
|
||||
| `kei-store` | v0.13.0 — memory-repo backend abstraction (GitHub / Forgejo / Gitea / Filesystem / S3) |
|
||||
| `kei-artifact` | v0.15.0 — typed artifact handoff pipeline (schema-validated content pass-between agents) |
|
||||
|
||||
## Primitives (shell)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
mod assembler;
|
||||
mod manifest;
|
||||
mod placeholders;
|
||||
mod schemas_export;
|
||||
mod validator;
|
||||
|
||||
use manifest::Manifest;
|
||||
|
|
|
|||
136
_assembler/src/schemas_export.rs
Normal file
136
_assembler/src/schemas_export.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! Dynamic artifact-schema whitelist loader.
|
||||
//!
|
||||
//! v0.16: the assembler previously hardcoded the 5 builtin schema names.
|
||||
//! That blocked any user who registered a custom schema via
|
||||
//! `kei-artifact register-schema` — the assembler would reject manifests
|
||||
//! referencing it. This cube loads the current registry from the export
|
||||
//! file written by `kei-artifact export-schemas`.
|
||||
//!
|
||||
//! Priority (first hit wins):
|
||||
//! 1. `$AGENT_ROOT/artifacts/schemas.json` (derived from `blocks_dir.parent()`)
|
||||
//! 2. `~/.claude/agents/artifacts/schemas.json`
|
||||
//! 3. Built-in fallback (5 names)
|
||||
//!
|
||||
//! Export file format: `{"schemas": ["spec", "plan", ...]}`. Builtins are
|
||||
//! always unioned in, so a hand-crafted export cannot drop a core schema.
|
||||
//!
|
||||
//! Constructor Pattern: no dependency on serde_json — minimal hand-parser
|
||||
//! keeps the assembler lean and free of transitive deps.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Canonical artifact schema names shipped by `kei-artifact`.
|
||||
///
|
||||
/// MIRROR OF `kei-artifact/src/schemas.rs::BUILTIN` (by design — assembler
|
||||
/// crate must not link to the runtime primitive). Drift is detected by the
|
||||
/// `builtin_schemas_do_not_drift` test in `validator.rs`.
|
||||
pub const BUILTIN: &[&str] = &["spec", "plan", "patch", "review", "research"];
|
||||
|
||||
/// Union of builtins + any names found in an on-disk export, as a sorted set.
|
||||
pub fn load(blocks_dir: &Path) -> BTreeSet<String> {
|
||||
load_with_home(blocks_dir, std::env::var("HOME").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Test-friendly variant that accepts an explicit HOME override.
|
||||
pub fn load_with_home(blocks_dir: &Path, home: Option<&str>) -> BTreeSet<String> {
|
||||
let mut out: BTreeSet<String> = BUILTIN.iter().map(|s| (*s).to_string()).collect();
|
||||
for path in candidate_paths(blocks_dir, home) {
|
||||
if let Some(names) = read_export(&path) {
|
||||
out.extend(names);
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn candidate_paths(blocks_dir: &Path, home: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(root) = blocks_dir.parent() {
|
||||
v.push(root.join("artifacts/schemas.json"));
|
||||
}
|
||||
if let Some(h) = home {
|
||||
v.push(PathBuf::from(h).join(".claude/agents/artifacts/schemas.json"));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn read_export(path: &Path) -> Option<Vec<String>> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
parse_export(&text)
|
||||
}
|
||||
|
||||
/// Minimal parser for `{"schemas": ["a", "b"]}`. Tolerant of whitespace.
|
||||
pub fn parse_export(text: &str) -> Option<Vec<String>> {
|
||||
let body = text.trim();
|
||||
let key = "\"schemas\"";
|
||||
let i = body.find(key)?;
|
||||
let rest = &body[i + key.len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
|
||||
let open = rest.find('[')?;
|
||||
let close = rest[open..].find(']')?;
|
||||
let inner = &rest[open + 1..open + close];
|
||||
let mut names = Vec::new();
|
||||
for tok in inner.split(',') {
|
||||
let t = tok.trim().trim_matches('"').trim();
|
||||
if !t.is_empty() {
|
||||
names.push(t.to_string());
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_happy_path() {
|
||||
let body = r#"{"schemas": ["spec", "plan", "custom-one"]}"#;
|
||||
assert_eq!(parse_export(body).unwrap(), vec!["spec", "plan", "custom-one"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_whitespace_and_newlines() {
|
||||
let body = "{\n \"schemas\" : [\n \"a\",\n \"b\"\n ]\n}\n";
|
||||
assert_eq!(parse_export(body).unwrap(), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_malformed() {
|
||||
assert!(parse_export("{}").is_none());
|
||||
assert!(parse_export(r#"{"schemas":"spec"}"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_falls_back_to_builtin_when_no_export() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let blocks_dir = tmp.path().join("_blocks");
|
||||
std::fs::create_dir_all(&blocks_dir).unwrap();
|
||||
// Isolated HOME (under tmp) — no real export file at that path.
|
||||
let home = tmp.path().to_string_lossy().to_string();
|
||||
let known = load_with_home(&blocks_dir, Some(&home));
|
||||
for s in BUILTIN {
|
||||
assert!(known.contains(*s));
|
||||
}
|
||||
assert_eq!(known.len(), BUILTIN.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_unions_with_custom_export() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let blocks_dir = tmp.path().join("_blocks");
|
||||
std::fs::create_dir_all(&blocks_dir).unwrap();
|
||||
let export = tmp.path().join("artifacts/schemas.json");
|
||||
std::fs::create_dir_all(export.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&export,
|
||||
r#"{"schemas": ["spec", "plan", "patch", "review", "research", "runbook"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let known = load_with_home(&blocks_dir, None);
|
||||
assert!(known.contains("runbook"));
|
||||
for s in BUILTIN {
|
||||
assert!(known.contains(*s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,19 +2,22 @@
|
|||
//! 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
|
||||
//! - `placeholders::check` — {{PLACEHOLDER}} substitution guard
|
||||
//! - `schemas_export::load` — dynamic artifact-schema whitelist loader
|
||||
//! - this file — structural checks + artifact-schema names
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
use crate::placeholders;
|
||||
use crate::schemas_export;
|
||||
use std::collections::BTreeSet;
|
||||
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"];
|
||||
/// Back-compat alias for external callers. The SSoT lives in
|
||||
/// `schemas_export::BUILTIN`.
|
||||
#[allow(dead_code)]
|
||||
pub const KNOWN_ARTIFACT_SCHEMAS: &[&str] = schemas_export::BUILTIN;
|
||||
|
||||
pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
|
||||
for required in OBLIGATORY {
|
||||
|
|
@ -45,36 +48,36 @@ pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
|
|||
}
|
||||
|
||||
placeholders::check(m)?;
|
||||
check_artifact_schemas(m)?;
|
||||
let known = schemas_export::load(blocks_dir);
|
||||
check_artifact_schemas(m, &known)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// known whitelist. Missing fields are allowed (non-breaking extension).
|
||||
fn check_artifact_schemas(m: &Manifest, known: &BTreeSet<String>) -> Result<(), String> {
|
||||
if let Some(name) = &m.produces_artifact {
|
||||
check_known(name, "produces_artifact")?;
|
||||
check_known(name, "produces_artifact", known)?;
|
||||
}
|
||||
for (i, h) in m.handoff.iter().enumerate() {
|
||||
if let Some(name) = &h.expects_artifact {
|
||||
check_known(name, &format!("handoff[{i}].expects_artifact"))?;
|
||||
check_known(name, &format!("handoff[{i}].expects_artifact"), known)?;
|
||||
}
|
||||
if let Some(name) = &h.produces_artifact {
|
||||
check_known(name, &format!("handoff[{i}].produces_artifact"))?;
|
||||
check_known(name, &format!("handoff[{i}].produces_artifact"), known)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_known(name: &str, field: &str) -> Result<(), String> {
|
||||
if KNOWN_ARTIFACT_SCHEMAS.iter().any(|s| *s == name) {
|
||||
fn check_known(name: &str, field: &str, known: &BTreeSet<String>) -> Result<(), String> {
|
||||
if known.contains(name) {
|
||||
return Ok(());
|
||||
}
|
||||
let list: Vec<&str> = known.iter().map(String::as_str).collect();
|
||||
Err(format!(
|
||||
"unknown artifact schema '{name}' in field '{field}' — must be one of {:?}",
|
||||
KNOWN_ARTIFACT_SCHEMAS
|
||||
"unknown artifact schema '{name}' in field '{field}' — must be one of {list:?}"
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -107,10 +110,14 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn builtin_set() -> BTreeSet<String> {
|
||||
schemas_export::BUILTIN.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_schemas_absent_passes() {
|
||||
let m = base();
|
||||
assert!(check_artifact_schemas(&m).is_ok());
|
||||
assert!(check_artifact_schemas(&m, &builtin_set()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -119,14 +126,14 @@ mod tests {
|
|||
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());
|
||||
assert!(check_artifact_schemas(&m, &builtin_set()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_schemas_reject_unknown_produces() {
|
||||
let mut m = base();
|
||||
m.produces_artifact = Some("not-a-schema".into());
|
||||
let err = check_artifact_schemas(&m).unwrap_err();
|
||||
let err = check_artifact_schemas(&m, &builtin_set()).unwrap_err();
|
||||
assert!(err.contains("not-a-schema"), "err: {err}");
|
||||
assert!(err.contains("produces_artifact"), "err: {err}");
|
||||
}
|
||||
|
|
@ -135,8 +142,39 @@ mod tests {
|
|||
fn artifact_schemas_reject_unknown_expects_in_handoff() {
|
||||
let mut m = base();
|
||||
m.handoff[0].expects_artifact = Some("zzz".into());
|
||||
let err = check_artifact_schemas(&m).unwrap_err();
|
||||
let err = check_artifact_schemas(&m, &builtin_set()).unwrap_err();
|
||||
assert!(err.contains("zzz"), "err: {err}");
|
||||
assert!(err.contains("handoff[0].expects_artifact"), "err: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_schemas_do_not_drift_from_kei_artifact() {
|
||||
// Structural drift test (no runtime dep on kei-artifact): read the
|
||||
// primitive's source and confirm its BUILTIN list matches ours.
|
||||
let primitive = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("_primitives/_rust/kei-artifact/src/schemas.rs");
|
||||
if !primitive.exists() {
|
||||
eprintln!("skip drift test: primitive not at {}", primitive.display());
|
||||
return;
|
||||
}
|
||||
let src = std::fs::read_to_string(&primitive).unwrap();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in src.lines() {
|
||||
let t = line.trim();
|
||||
if let Some(rest) = t.strip_prefix("(\"") {
|
||||
if let Some(end) = rest.find("\",") {
|
||||
names.push(rest[..end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mine: Vec<String> = schemas_export::BUILTIN
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names, mine,
|
||||
"kei-artifact BUILTIN and schemas_export::BUILTIN drifted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
_assembler/tests/mode_blocks.rs
Normal file
78
_assembler/tests/mode_blocks.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! Mode-picker integration test.
|
||||
//!
|
||||
//! The `skills/new-agent` wizard Phase 3.6 appends `mode-*` block names to
|
||||
//! the `blocks` array. This test locks the contract that such a manifest
|
||||
//! validates cleanly AND the expected mode files ship in `_blocks/` (either
|
||||
//! in the fixture set or alongside the real kit).
|
||||
//!
|
||||
//! We use the real `_blocks/` so the test protects the kit's mode surface —
|
||||
//! if anyone renames or deletes a mode block, the wizard's Phase 3.6
|
||||
//! selection would silently break at runtime otherwise.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn kit_root() -> PathBuf {
|
||||
// `CARGO_MANIFEST_DIR` points at `_assembler/`; kit root is one up.
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_five_mode_blocks_ship_in_kit() {
|
||||
let blocks = kit_root().join("_blocks");
|
||||
for mode in [
|
||||
"mode-skeptic",
|
||||
"mode-devils-advocate",
|
||||
"mode-minimalist",
|
||||
"mode-maximalist",
|
||||
"mode-first-principles",
|
||||
] {
|
||||
let p = blocks.join(format!("{mode}.md"));
|
||||
assert!(
|
||||
p.exists(),
|
||||
"mode block '{mode}' is missing from _blocks/ — Phase 3.6 of skills/new-agent would break"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_matrix_doc_ships_in_kit() {
|
||||
let p = kit_root().join("_blocks/mode-matrix.md");
|
||||
assert!(
|
||||
p.exists(),
|
||||
"mode-matrix.md is missing from _blocks/ — SKILL.md Phase 3.6 references it"
|
||||
);
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
// The matrix must enumerate each mode by block basename.
|
||||
for mode in [
|
||||
"skeptic",
|
||||
"devils-advocate",
|
||||
"minimalist",
|
||||
"maximalist",
|
||||
"first-principles",
|
||||
] {
|
||||
assert!(
|
||||
text.contains(mode),
|
||||
"mode-matrix.md is missing row for '{mode}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_md_phase_3_6_wiring_exists() {
|
||||
// The wizard adds mode-* blocks only if Phase 3.6 is present.
|
||||
let p = kit_root().join("skills/new-agent/SKILL.md");
|
||||
assert!(p.exists(), "skills/new-agent/SKILL.md is missing");
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("Phase 3.6"),
|
||||
"SKILL.md is missing the Phase 3.6 mode picker"
|
||||
);
|
||||
assert!(
|
||||
text.contains("mode-skeptic")
|
||||
|| text.contains("skeptic — doubt-first"),
|
||||
"SKILL.md Phase 3.6 does not reference the skeptic mode"
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ Composable behavioural skews. Add any combination to a manifest's `blocks` list
|
|||
| `mode-maximalist.md` | Explore 10× scope; return both maximum and minimum bounds; only when user invokes exploration |
|
||||
| `mode-first-principles.md` | Derive from invariants; cite the physical / mathematical constraint, not "best practice" |
|
||||
|
||||
See `mode-matrix.md` for the **agent-role × recommended-modes** table used by the `skills/new-agent` wizard (Phase 3.6). It is the suggested starting set per role — modes remain a free pick per manifest.
|
||||
|
||||
## Adding a new block
|
||||
|
||||
1. Pick a stable prefix (existing category or a new one documented here).
|
||||
|
|
|
|||
24
_blocks/mode-matrix.md
Normal file
24
_blocks/mode-matrix.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# MODE — Agent × Cognitive-Mode Matrix
|
||||
|
||||
Composable cognitive-mode blocks live in `_blocks/mode-*.md`. Any agent manifest can append them to its `blocks = [...]` list to stack the behavioural skew; modes compose (e.g. `mode-skeptic` + `mode-minimalist` = adversarial pruner).
|
||||
|
||||
This table is the suggested starting set per agent role. It is a **guide, not a rule** — pick what fits the agent's actual job.
|
||||
|
||||
| Agent role | Recommended modes | Reason |
|
||||
|---|---|---|
|
||||
| critic | `skeptic` · `devils-advocate` | Doubt-first review; name the strongest objection before agreeing |
|
||||
| validator | `skeptic` | Every claim needs an E1/E2 grade — no plausibility shortcuts |
|
||||
| security-auditor | `devils-advocate` · `skeptic` | Steel-man the attacker; threat-model the worst case |
|
||||
| researcher | `skeptic` | Cross-check every source; honest gaps over confident guesses |
|
||||
| ml-researcher | `skeptic` · `first-principles` | Paradigm-native measurement + invariant-derived priors |
|
||||
| architect | `first-principles` · `minimalist` | Derive from constraints, prefer subtraction over addition |
|
||||
| code-implementer | `minimalist` | Surgical edits; remove before adding |
|
||||
| refactor specialist | `minimalist` | Delete dead code; prove every kept line |
|
||||
| ml-implementer | `minimalist` · `first-principles` | Math-First — count params before code, derive over tune |
|
||||
| brainstorm / concept-explorer | `maximalist` | Return 10× version + minimum bounds; user invokes exploration |
|
||||
| physics-deriver | `first-principles` | Cite the invariant; no arguments from "best practice" |
|
||||
|
||||
Intentionally **unbiased** roles (pick 0 modes by default):
|
||||
- `infra-implementer`, `modal-runner`, `fal-ai-runner`, `cost-guardian`, most `kei-<project>-specialist` agents.
|
||||
|
||||
Modes are not free — each one lands verbatim in the prompt and consumes context. Stack only what you need.
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
use crate::hash::artifact_id;
|
||||
use crate::store::Store;
|
||||
use crate::validate::validate_content;
|
||||
use crate::validate::{validate_content, warn_unsupported_keywords};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
|
@ -31,11 +31,18 @@ pub struct ArtifactFilter {
|
|||
}
|
||||
|
||||
/// Insert a schema under `name`. Overwrite if present (idempotent registry).
|
||||
///
|
||||
/// Non-fatal audit: unsupported JSON Schema keywords (`pattern`, `format`,
|
||||
/// `oneOf`, `$ref`, etc.) are logged to stderr via
|
||||
/// [`warn_unsupported_keywords`] so the operator knows they are stored but not
|
||||
/// runtime-enforced. This keeps the validator surface minimal while letting
|
||||
/// humans leave documentation-style keywords in place.
|
||||
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"));
|
||||
}
|
||||
warn_unsupported_keywords(&parsed);
|
||||
let now = Utc::now().timestamp();
|
||||
store.conn().execute(
|
||||
"INSERT INTO schemas (name, json_schema, registered_at) VALUES (?1, ?2, ?3)
|
||||
|
|
|
|||
126
_primitives/_rust/kei-artifact/src/cli_cmds.rs
Normal file
126
_primitives/_rust/kei-artifact/src/cli_cmds.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! CLI command bodies for artifact CRUD (emit / get / list / chain).
|
||||
//!
|
||||
//! Constructor Pattern: one file for the read/write-artifact commands,
|
||||
//! kept separate from main.rs so the binary file stays <200 LOC.
|
||||
//! Each public `cmd_*` fn < 30 LOC.
|
||||
|
||||
use kei_artifact::artifact::{chain, emit, get, list, ArtifactFilter};
|
||||
use kei_artifact::Store;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn cmd_emit(
|
||||
store: &Store,
|
||||
schema: &str,
|
||||
from: &str,
|
||||
content_path: &Path,
|
||||
meta: &[String],
|
||||
parent: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let bytes = std::fs::read(content_path)?;
|
||||
let meta_json = if meta.is_empty() { None } else { Some(encode_meta(meta)?) };
|
||||
let id = emit(store, schema, from, &bytes, meta_json.as_deref(), parent)?;
|
||||
println!("{id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_meta(kvs: &[String]) -> anyhow::Result<String> {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for kv in kvs {
|
||||
let (k, v) = kv
|
||||
.split_once('=')
|
||||
.ok_or_else(|| anyhow::anyhow!("--meta expects key=value: {kv}"))?;
|
||||
obj.insert(k.to_string(), serde_json::Value::String(v.to_string()));
|
||||
}
|
||||
Ok(serde_json::Value::Object(obj).to_string())
|
||||
}
|
||||
|
||||
pub fn cmd_get(store: &Store, id: &str, format: &str) -> anyhow::Result<()> {
|
||||
let a = get(store, id)?.ok_or_else(|| anyhow::anyhow!("artifact not found: {id}"))?;
|
||||
match format {
|
||||
"json" => print_json(&a)?,
|
||||
"md" | "typed" => print_typed(&a)?,
|
||||
other => return Err(anyhow::anyhow!("unknown format '{other}'")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_json(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(a)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_typed(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
|
||||
let text = std::str::from_utf8(&a.content).unwrap_or("<binary>");
|
||||
println!("# artifact {} (schema={}, from={})", a.id, a.schema_name, a.source_agent);
|
||||
println!("{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_list(
|
||||
store: &Store,
|
||||
schema: Option<&str>,
|
||||
from: Option<&str>,
|
||||
since: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let filter = ArtifactFilter {
|
||||
schema_name: schema.map(str::to_string),
|
||||
source_agent: from.map(str::to_string),
|
||||
since: since.and_then(parse_since),
|
||||
};
|
||||
for a in list(store, &filter)? {
|
||||
println!("{}\t{}\t{}\t{}", a.id, a.schema_name, a.source_agent, a.created_at);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_since(s: &str) -> Option<i64> {
|
||||
// Accept raw epoch ints, or "1d" / "2h" / "30m" shorthands.
|
||||
if let Ok(n) = s.parse::<i64>() {
|
||||
return Some(n);
|
||||
}
|
||||
let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
|
||||
let n: i64 = num.parse().ok()?;
|
||||
let secs = match unit {
|
||||
"s" => n,
|
||||
"m" => n * 60,
|
||||
"h" => n * 3600,
|
||||
"d" => n * 86400,
|
||||
_ => return None,
|
||||
};
|
||||
Some(chrono::Utc::now().timestamp() - secs)
|
||||
}
|
||||
|
||||
pub 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_meta_single_pair() {
|
||||
let s = encode_meta(&["role=architect".to_string()]).unwrap();
|
||||
assert!(s.contains("\"role\""));
|
||||
assert!(s.contains("\"architect\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_meta_rejects_missing_equals() {
|
||||
let err = encode_meta(&["no-equals".to_string()]).unwrap_err();
|
||||
assert!(format!("{err}").contains("no-equals"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_since_accepts_raw_epoch() {
|
||||
assert_eq!(parse_since("1700000000"), Some(1700000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_since_rejects_bad_unit() {
|
||||
assert!(parse_since("5y").is_none());
|
||||
}
|
||||
}
|
||||
82
_primitives/_rust/kei-artifact/src/export.rs
Normal file
82
_primitives/_rust/kei-artifact/src/export.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
//! v0.16: schema-registry export.
|
||||
//!
|
||||
//! Writes the current list of registered schema names as JSON at a path the
|
||||
//! assembler's manifest validator reads to accept custom-registered schemas
|
||||
//! without a rebuild.
|
||||
//!
|
||||
//! Format: `{"schemas": ["spec", "plan", ...]}` with a trailing newline.
|
||||
//!
|
||||
//! Constructor Pattern: one cube, one responsibility. Tests live inline —
|
||||
//! `render()` is pure, so we exercise it without a Store.
|
||||
|
||||
use crate::artifact::list_schemas;
|
||||
use crate::store::Store;
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Write the current schemas registry to `override_path` or the default
|
||||
/// umbrella path. Returns the number of schemas written + the final path.
|
||||
pub fn write(store: &Store, override_path: Option<&Path>) -> Result<(usize, PathBuf)> {
|
||||
let names = list_schemas(store)?;
|
||||
let json = render(&names);
|
||||
let target = override_path
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(default_path);
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&target, json)?;
|
||||
Ok((names.len(), target))
|
||||
}
|
||||
|
||||
/// Serialize `names` as `{"schemas": ["a", "b"]}\n`.
|
||||
pub fn render(names: &[String]) -> String {
|
||||
let quoted: Vec<String> = names.iter().map(|n| format!("\"{n}\"")).collect();
|
||||
format!("{{\"schemas\": [{}]}}\n", quoted.join(", "))
|
||||
}
|
||||
|
||||
/// `~/.claude/agents/artifacts/schemas.json` (consumed by the assembler).
|
||||
pub fn default_path() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".claude/agents/artifacts/schemas.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_empty_list() {
|
||||
assert_eq!(render(&[]), "{\"schemas\": []}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_five_builtins() {
|
||||
let names: Vec<String> =
|
||||
["spec", "plan", "patch", "review", "research"].iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(
|
||||
render(&names),
|
||||
"{\"schemas\": [\"spec\", \"plan\", \"patch\", \"review\", \"research\"]}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_file_and_parent_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = Store::open_memory().unwrap();
|
||||
crate::schemas::register_builtins(&store).unwrap();
|
||||
crate::artifact::register_schema(
|
||||
&store,
|
||||
"custom",
|
||||
r#"{"type":"object","additionalProperties":false,"properties":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let target = tmp.path().join("nested/dir/schemas.json");
|
||||
let (n, path) = write(&store, Some(&target)).unwrap();
|
||||
assert_eq!(n, 6);
|
||||
assert_eq!(path, target);
|
||||
let body = std::fs::read_to_string(&target).unwrap();
|
||||
assert!(body.contains("custom"));
|
||||
assert!(body.contains("spec"));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,13 @@
|
|||
//! - `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).
|
||||
//! - `export` — v0.16 schema-registry export for the assembler.
|
||||
//!
|
||||
//! Storage path (CLI default): `~/.claude/artifacts/artifacts.sqlite` or
|
||||
//! `$KEI_ARTIFACT_DB`.
|
||||
|
||||
pub mod artifact;
|
||||
pub mod export;
|
||||
pub mod hash;
|
||||
pub mod schema;
|
||||
pub mod schemas;
|
||||
|
|
@ -21,4 +23,4 @@ pub mod validate;
|
|||
pub use artifact::{Artifact, ArtifactFilter};
|
||||
pub use hash::artifact_id;
|
||||
pub use store::Store;
|
||||
pub use validate::validate_content;
|
||||
pub use validate::{validate_content, warn_unsupported_keywords};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
//! kei-artifact CLI — register-schema / emit / get / list / validate / chain.
|
||||
//!
|
||||
//! Constructor Pattern: main.rs = dispatch only. Each `cmd_*` fn < 30 LOC.
|
||||
//! Artifact-CRUD command bodies live in the `cli_cmds` sibling module.
|
||||
|
||||
mod cli_cmds;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use kei_artifact::artifact::{chain, emit, get, list, register_schema, validate_by_id, ArtifactFilter};
|
||||
use kei_artifact::artifact::{list_schemas, register_schema, validate_by_id};
|
||||
use kei_artifact::export::write as export_write;
|
||||
use kei_artifact::schemas::register_builtins;
|
||||
use kei_artifact::Store;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -22,11 +26,20 @@ struct Cli {
|
|||
enum Cmd {
|
||||
/// Initialise the db and register the 5 built-in schemas.
|
||||
Init,
|
||||
/// Register a JSON Schema file under a name.
|
||||
/// Register a JSON Schema file under a name. Also refreshes the export
|
||||
/// consumed by the assembler (see `ExportSchemas`).
|
||||
RegisterSchema {
|
||||
#[arg(long)] name: String,
|
||||
#[arg(long)] path: PathBuf,
|
||||
},
|
||||
/// List all registered schema names, one per line (plain text).
|
||||
ListSchemas,
|
||||
/// v0.16: write the current schema-name list as JSON so the assembler's
|
||||
/// manifest validator can accept custom-registered schemas without a
|
||||
/// rebuild. Default path: `~/.claude/agents/artifacts/schemas.json`.
|
||||
ExportSchemas {
|
||||
#[arg(long)] path: Option<PathBuf>,
|
||||
},
|
||||
/// Emit an artifact. Content file must be JSON.
|
||||
Emit {
|
||||
#[arg(long)] schema: String,
|
||||
|
|
@ -73,15 +86,17 @@ 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::ListSchemas => cmd_list_schemas(store),
|
||||
Cmd::ExportSchemas { path } => cmd_export_schemas(store, path.as_deref()),
|
||||
Cmd::Emit { schema, from, content, meta, parent } => {
|
||||
cmd_emit(store, &schema, &from, &content, &meta, parent.as_deref())
|
||||
cli_cmds::cmd_emit(store, &schema, &from, &content, &meta, parent.as_deref())
|
||||
}
|
||||
Cmd::Get { id, format } => cmd_get(store, &id, &format),
|
||||
Cmd::Get { id, format } => cli_cmds::cmd_get(store, &id, &format),
|
||||
Cmd::List { schema, from, since } => {
|
||||
cmd_list(store, schema.as_deref(), from.as_deref(), since.as_deref())
|
||||
cli_cmds::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),
|
||||
Cmd::Chain { id } => cli_cmds::cmd_chain(store, &id),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,95 +110,25 @@ fn cmd_register(store: &Store, name: &str, path: &std::path::Path) -> anyhow::Re
|
|||
let text = std::fs::read_to_string(path)?;
|
||||
register_schema(store, name, &text)?;
|
||||
println!("registered schema '{name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_emit(
|
||||
store: &Store,
|
||||
schema: &str,
|
||||
from: &str,
|
||||
content_path: &std::path::Path,
|
||||
meta: &[String],
|
||||
parent: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let bytes = std::fs::read(content_path)?;
|
||||
let meta_json = if meta.is_empty() { None } else { Some(encode_meta(meta)?) };
|
||||
let id = emit(store, schema, from, &bytes, meta_json.as_deref(), parent)?;
|
||||
println!("{id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_meta(kvs: &[String]) -> anyhow::Result<String> {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for kv in kvs {
|
||||
let (k, v) = kv
|
||||
.split_once('=')
|
||||
.ok_or_else(|| anyhow::anyhow!("--meta expects key=value: {kv}"))?;
|
||||
obj.insert(k.to_string(), serde_json::Value::String(v.to_string()));
|
||||
}
|
||||
Ok(serde_json::Value::Object(obj).to_string())
|
||||
}
|
||||
|
||||
fn cmd_get(store: &Store, id: &str, format: &str) -> anyhow::Result<()> {
|
||||
let a = get(store, id)?.ok_or_else(|| anyhow::anyhow!("artifact not found: {id}"))?;
|
||||
match format {
|
||||
"json" => print_json(&a)?,
|
||||
"md" | "typed" => print_typed(&a)?,
|
||||
other => return Err(anyhow::anyhow!("unknown format '{other}'")),
|
||||
// Best-effort auto-refresh the export so the assembler sees the new
|
||||
// schema without a manual `export-schemas` call. A write failure
|
||||
// (perm / missing parent) is non-fatal: register succeeded.
|
||||
if let Err(e) = cmd_export_schemas(store, None) {
|
||||
eprintln!("warning: export refresh failed: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_json(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(a)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_typed(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
|
||||
let text = std::str::from_utf8(&a.content).unwrap_or("<binary>");
|
||||
println!("# artifact {} (schema={}, from={})", a.id, a.schema_name, a.source_agent);
|
||||
println!("{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_list(
|
||||
store: &Store,
|
||||
schema: Option<&str>,
|
||||
from: Option<&str>,
|
||||
since: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let filter = ArtifactFilter {
|
||||
schema_name: schema.map(str::to_string),
|
||||
source_agent: from.map(str::to_string),
|
||||
since: since.and_then(parse_since),
|
||||
};
|
||||
for a in list(store, &filter)? {
|
||||
println!("{}\t{}\t{}\t{}", a.id, a.schema_name, a.source_agent, a.created_at);
|
||||
fn cmd_list_schemas(store: &Store) -> anyhow::Result<()> {
|
||||
for name in list_schemas(store)? {
|
||||
println!("{name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_since(s: &str) -> Option<i64> {
|
||||
// Accept raw epoch ints, or "1d" / "2h" / "30m" shorthands.
|
||||
if let Ok(n) = s.parse::<i64>() {
|
||||
return Some(n);
|
||||
}
|
||||
let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
|
||||
let n: i64 = num.parse().ok()?;
|
||||
let secs = match unit {
|
||||
"s" => n,
|
||||
"m" => n * 60,
|
||||
"h" => n * 3600,
|
||||
"d" => n * 86400,
|
||||
_ => return None,
|
||||
};
|
||||
Some(chrono::Utc::now().timestamp() - secs)
|
||||
}
|
||||
|
||||
fn cmd_chain(store: &Store, id: &str) -> anyhow::Result<()> {
|
||||
for a in chain(store, id)? {
|
||||
println!("{}\t{}\t{}", a.id, a.schema_name, a.source_agent);
|
||||
}
|
||||
fn cmd_export_schemas(store: &Store, override_path: Option<&std::path::Path>) -> anyhow::Result<()> {
|
||||
let (count, target) = export_write(store, override_path)?;
|
||||
println!("exported {count} schemas → {}", target.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -291,6 +291,39 @@ Store the resolved value as `FINAL_NAME`. All subsequent phases use `FINAL_NAME`
|
|||
|
||||
---
|
||||
|
||||
## Phase 3.6 — Cognitive modes (AskUserQuestion, ONE call, optional)
|
||||
|
||||
Cognitive-mode blocks (`_blocks/mode-*.md`) add a behavioural skew to the generated agent. They compose — multi-selection is expected. **Default: pick NONE** if unsure; modes are not free (each lands verbatim in the prompt).
|
||||
|
||||
See `_blocks/mode-matrix.md` for the recommended starting set per agent role.
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Add cognitive mode blocks?",
|
||||
"header": "Modes",
|
||||
"multiSelect": true,
|
||||
"options": [
|
||||
{"label": "skeptic — doubt-first", "description": "Ask 'what's the evidence?' on every claim. Good for critics, validators, researchers."},
|
||||
{"label": "devils-advocate — steel-man opposite", "description": "Name the strongest objection before agreeing. Good for security auditors, code reviewers."},
|
||||
{"label": "minimalist — subtract over add", "description": "Justify every addition against existing code. Good for refactor, architect, ml-implementer."},
|
||||
{"label": "maximalist — 10× version", "description": "Return both maximum and minimum bounds. Good for brainstorm / concept-exploration agents."},
|
||||
{"label": "first-principles — derive from invariants", "description": "Cite the physical / mathematical constraint, not 'best practice'. Good for architects, physicists, systems-designers."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Resolve: map each selected label to its block name (`mode-skeptic`, `mode-devils-advocate`, `mode-minimalist`, `mode-maximalist`, `mode-first-principles`) and APPEND them to the `blocks` array computed in Phase 3.1 — after the stack/deploy/domain blocks, in the order the user picked them.
|
||||
|
||||
If the user selected nothing — skip. The manifest is valid with zero mode blocks (the 12 existing kit manifests ship that way).
|
||||
|
||||
Pre-flight still applies: Phase 3.2 existence-check must cover any mode blocks added here.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Fill the template + write the manifest
|
||||
|
||||
1. Read `~/.claude/agents/_templates/specialist.toml.template` via the Read tool.
|
||||
|
|
|
|||
Loading…
Reference in a new issue