17-task TDD plan covering: typed signal scaffolding (kebab-app error_signal module), ConfigInvalid + NotIndexed typed errors, SchemaV1 struct + schema_with_config facade, count_summary helper, wire_schema + wire_error_v1 helpers, error_classify dispatcher, Cmd::Schema CLI arm, --json mode error.v1 emission, JSON Schema literals, doc sync. Each task = bite-sized TDD cycle (write failing test → impl → verify pass → commit). Final task = workspace clippy + cargo test --workspace -j 1 + manual smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
61 KiB
p9-fb-27 Implementation Plan — Introspection + structured error wire
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add kebab schema [--json] introspection command + error.v1 wire schema for --json mode fatal errors. Unblocks fb-30 (MCP) and gives agents a stable surface for capability/version discovery and machine-readable error parsing.
Architecture: Two surfaces, one PR. (1) kebab schema builds a SchemaV1 snapshot (wire / capabilities / models / stats) via a new kebab_app::schema_with_config(&Config) facade. (2) error.v1 is emitted by a new kebab-cli::error_classify::classify(&anyhow::Error) function that downcasts known typed errors (LlmError, new ConfigInvalid, new NotIndexed) into structured code / message / details / hint records. Existing typed signals (RefusalSignal, NoHitSignal, DoctorUnhealthy) continue to drive exit codes 1 / 1 / 3 unchanged. Non---json stderr text path is untouched.
Tech Stack: Rust 2024 workspace, serde + serde_json (existing), thiserror (existing in kebab-llm-local), anyhow (existing). No new dependencies.
Spec source: docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md (commit f01f8df).
File map
Create:
crates/kebab-app/src/error_signal.rs— re-exports + new typed signal definitionscrates/kebab-app/src/schema.rs— SchemaV1 struct + schema_with_config facadecrates/kebab-cli/src/error_classify.rs— anyhow::Error → ErrorV1 dispatchercrates/kebab-app/tests/schema_report.rs— integration test for facadecrates/kebab-cli/tests/cli_schema.rs— binary spawn test forkebab schema --jsoncrates/kebab-cli/tests/cli_error_wire.rs— binary spawn test for error.v1 emissiondocs/wire-schema/v1/schema.schema.json— JSON Schema literal for schema.v1docs/wire-schema/v1/error.schema.json— JSON Schema literal for error.v1
Modify:
crates/kebab-app/src/lib.rs— addpub mod error_signal;+pub mod schema;+ re-exportSchemaV1,schema_with_configcrates/kebab-config/src/lib.rs— addConfigInvalidtyped error + wrapfrom_fileerrorscrates/kebab-store-sqlite/src/store.rs— addNotIndexedtyped error + wrap missing-DB / migration pathscrates/kebab-cli/src/main.rs— addCmd::Schemaarm, replaceErr(e)arm with json-mode classify branch, register new modulecrates/kebab-cli/src/wire.rs— addwire_schema+wire_error_v1helperstasks/p9/p9-fb-27-introspection-and-error-wire.md— flipstatus: open→completedtasks/HOTFIXES.md— add2026-05-?? — fb-27entryHANDOFF.md— add one-line entry under "머지 후 발견된 결정"README.md— addkebab schemarow to 명령 tableCLAUDE.md— addschema.v1/error.v1to wire schema listintegrations/claude-code/kebab/SKILL.md— additive note aboutkebab schemafor capability discoverydocs/superpowers/specs/2026-04-27-kebab-final-form-design.md— §10 add capability matrix subsection + wire schema list extension
Task 1: Define new typed signal module skeleton
Files:
-
Create:
crates/kebab-app/src/error_signal.rs -
Modify:
crates/kebab-app/src/lib.rs -
Step 1: Create
crates/kebab-app/src/error_signal.rs
//! Typed signal re-exports + new signals introduced by fb-27.
//!
//! kebab-cli (and future kebab-tui / kebab-desktop) downcast on these to
//! build `error.v1` wire records. The existing signals
//! (`RefusalSignal`, `NoHitSignal`, `DoctorUnhealthy`) live in
//! `doctor_signal.rs` — leave those unchanged and re-export via this
//! module so callers have one place to import from.
//!
//! See `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
pub use crate::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
pub use kebab_config::ConfigInvalid;
pub use kebab_llm_local::LlmError;
pub use kebab_store_sqlite::NotIndexed;
- Step 2: Wire the module into
crates/kebab-app/src/lib.rs
Find the existing line pub mod doctor_signal; (search for it; it's near the top of lib.rs). Add this line right after it:
pub mod error_signal;
- Step 3: Verify the module skeleton compiles
Run: cargo check -p kebab-app
Expected: build fails because kebab_config::ConfigInvalid and kebab_store_sqlite::NotIndexed do not exist yet. This is fine — we wire them up in Tasks 2 and 3. The point of this step is to confirm the module file is registered.
If the failure is anything other than missing ConfigInvalid / NotIndexed, stop and investigate.
- Step 4: Comment out the not-yet-defined re-exports temporarily
Edit crates/kebab-app/src/error_signal.rs and replace the bottom three pub use lines with:
pub use kebab_llm_local::LlmError;
// pub use kebab_config::ConfigInvalid; // wired in Task 2
// pub use kebab_store_sqlite::NotIndexed; // wired in Task 3
- Step 5: Verify compile succeeds
Run: cargo check -p kebab-app
Expected: PASS.
- Step 6: Commit
git add crates/kebab-app/src/error_signal.rs crates/kebab-app/src/lib.rs
git commit -m "$(cat <<'EOF'
🏗️ chore(kebab-app): scaffold error_signal module (fb-27)
Re-exports existing doctor_signal entries (RefusalSignal / NoHitSignal /
DoctorUnhealthy) + LlmError from kebab-llm-local. ConfigInvalid /
NotIndexed re-exports added in subsequent tasks once the source crates
define them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: Add ConfigInvalid typed error to kebab-config
Files:
-
Modify:
crates/kebab-config/src/lib.rs -
Test: same file (
#[cfg(test)] mod testsor new top-level module) -
Step 1: Write the failing test for ConfigInvalid downcast
Add to the bottom of crates/kebab-config/src/lib.rs (inside the existing #[cfg(test)] mod tests if present, otherwise create one):
#[cfg(test)]
mod fb27_tests {
use super::*;
use std::path::PathBuf;
#[test]
fn config_invalid_carries_path_and_cause() {
let nonexistent = PathBuf::from("/this/path/should/not/exist/kebab.toml");
let err = Config::from_file(&nonexistent).unwrap_err();
let signal = err.downcast_ref::<ConfigInvalid>()
.expect("from_file error should downcast to ConfigInvalid");
assert_eq!(signal.path, nonexistent);
assert!(!signal.cause.is_empty(), "cause should be non-empty");
}
#[test]
fn config_invalid_on_malformed_toml() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("bad.toml");
std::fs::write(&p, "this is not [valid toml").unwrap();
let err = Config::from_file(&p).unwrap_err();
let signal = err.downcast_ref::<ConfigInvalid>()
.expect("malformed TOML should downcast to ConfigInvalid");
assert_eq!(signal.path, p);
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p kebab-config fb27_tests -- --nocapture
Expected: FAIL — cannot find type ConfigInvalid in this scope.
- Step 3: Define the ConfigInvalid type at the top of
crates/kebab-config/src/lib.rs
Find a spot near the top of the file (after the module-level doc-comment and use statements, before the first pub struct). Add:
use std::path::PathBuf;
/// Signal: `Config::from_file` / `Config::load` failed due to missing path,
/// I/O failure, TOML parse failure, or post-parse validation failure.
///
/// Wrapped into `anyhow::Error` at the API boundary so callers that need
/// structured details (e.g. kebab-cli's `error_classify`) can
/// `downcast_ref::<ConfigInvalid>()` for the wire record.
#[derive(Debug, thiserror::Error)]
#[error("config invalid at {path}: {cause}")]
pub struct ConfigInvalid {
pub path: PathBuf,
pub cause: String,
}
If thiserror is not already a dependency of kebab-config, add it to crates/kebab-config/Cargo.toml:
thiserror = { workspace = true }
(check Cargo.toml workspace dependencies first — thiserror is already used by other crates so the workspace entry should exist).
- Step 4: Wrap from_file error paths
Find pub fn from_file(path: &Path) -> anyhow::Result<Self> in crates/kebab-config/src/lib.rs. Modify it so every Err branch wraps the underlying error in ConfigInvalid. Example pattern:
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let raw = std::fs::read_to_string(path).map_err(|e| {
anyhow::Error::new(ConfigInvalid {
path: path.to_path_buf(),
cause: format!("read failed: {e}"),
})
})?;
let mut cfg: Config = toml::from_str(&raw).map_err(|e| {
anyhow::Error::new(ConfigInvalid {
path: path.to_path_buf(),
cause: format!("parse failed: {e}"),
})
})?;
cfg.source_dir = path.parent().map(PathBuf::from);
cfg.validate().map_err(|e| {
anyhow::Error::new(ConfigInvalid {
path: path.to_path_buf(),
cause: format!("validation failed: {e}"),
})
})?;
Ok(cfg)
}
(Adapt to whatever the actual existing function does — preserve all current behavior, just add the wrapping.) The key invariant: after this task, every error returned by from_file must be downcastable to ConfigInvalid.
- Step 5: Run test to verify pass
Run: cargo test -p kebab-config fb27_tests -- --nocapture
Expected: PASS.
- Step 6: Verify existing tests still pass
Run: cargo test -p kebab-config
Expected: PASS, no regressions.
- Step 7: Uncomment the kebab-app re-export
Edit crates/kebab-app/src/error_signal.rs. Uncomment the pub use kebab_config::ConfigInvalid; line.
Run: cargo check -p kebab-app
Expected: PASS.
- Step 8: Commit
git add crates/kebab-config/src/lib.rs crates/kebab-config/Cargo.toml crates/kebab-app/src/error_signal.rs
git commit -m "$(cat <<'EOF'
🏗️ feat(kebab-config): add ConfigInvalid typed error (fb-27)
Wraps every error path in `Config::from_file` (read failure, TOML parse,
validation) so downstream callers can `downcast_ref::<ConfigInvalid>()`
to build the `error.v1` wire record. kebab-app re-exports the type via
its `error_signal` module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: Add NotIndexed typed error to kebab-store-sqlite
Files:
-
Modify:
crates/kebab-store-sqlite/src/store.rs(or the file containingSqliteStore::open) -
Test: same crate
-
Step 1: Find the existing open / migrate entry point
Run: grep -n "fn open\|fn migrate\|pub fn new" crates/kebab-store-sqlite/src/store.rs | head -10
Note the file + line of SqliteStore::open (or the equivalent). This is where the missing-DB / schema-mismatch detection lives.
- Step 2: Write the failing test
Add to crates/kebab-store-sqlite/src/store.rs (in the #[cfg(test)] mod tests block at the bottom — search for it):
#[test]
fn not_indexed_signal_emitted_when_db_missing() {
let dir = tempfile::tempdir().unwrap();
let nonexistent_db = dir.path().join("does-not-exist.sqlite");
// Use whatever `open` API the crate exposes; this is the most likely
// shape based on existing tests:
let res = SqliteStore::open_existing(&nonexistent_db);
let err = res.expect_err("opening a missing DB should fail");
let signal = err.downcast_ref::<NotIndexed>()
.expect("missing DB error should downcast to NotIndexed");
assert_eq!(signal.expected, nonexistent_db.to_string_lossy());
}
If SqliteStore::open_existing does not exist as a separate API from SqliteStore::open (which auto-creates), introduce one — see Step 4. Adapt the test name to match the introduced API.
- Step 3: Run test to verify it fails
Run: cargo test -p kebab-store-sqlite not_indexed_signal -- --nocapture
Expected: FAIL — NotIndexed not defined.
- Step 4: Define
NotIndexedand the open_existing API
Add to crates/kebab-store-sqlite/src/store.rs (top of file, near other type definitions):
/// Signal: SQLite database file does not exist, or schema_version does
/// not match the binary's expectation.
///
/// Distinct from generic I/O / SQL errors so kebab-cli can surface
/// `code: "not_indexed"` with a hint to run `kebab init` / `kebab ingest`.
#[derive(Debug, thiserror::Error)]
#[error("not indexed: expected={expected}, found={found:?}")]
pub struct NotIndexed {
pub expected: String,
pub found: Option<String>,
}
Make sure thiserror = { workspace = true } is in crates/kebab-store-sqlite/Cargo.toml.
Add a public open_existing method on SqliteStore — it differs from the existing open (which auto-creates) by returning NotIndexed when the DB file is absent:
impl SqliteStore {
/// Open an existing SQLite DB at `path`. Unlike `open`, this does NOT
/// create the file — if it is missing, returns a `NotIndexed` signal
/// suitable for `error.v1` translation.
pub fn open_existing(path: &std::path::Path) -> anyhow::Result<Self> {
if !path.exists() {
return Err(anyhow::Error::new(NotIndexed {
expected: path.to_string_lossy().to_string(),
found: None,
}));
}
Self::open(path)
}
}
If open already detects schema mismatch and returns an error, also wrap that error as NotIndexed with found: Some(actual_version_str). (Inspect existing migration code; the schema_version row is in the _refinery_schema_history table.)
- Step 5: Run test to verify pass
Run: cargo test -p kebab-store-sqlite not_indexed_signal -- --nocapture
Expected: PASS.
- Step 6: Verify existing tests still pass
Run: cargo test -p kebab-store-sqlite
Expected: PASS, no regressions. (If anything fails, the new NotIndexed wrapping is too broad — narrow it back.)
- Step 7: Uncomment the kebab-app re-export
Edit crates/kebab-app/src/error_signal.rs. Uncomment the pub use kebab_store_sqlite::NotIndexed; line.
Run: cargo check -p kebab-app
Expected: PASS.
- Step 8: Commit
git add crates/kebab-store-sqlite/src/store.rs crates/kebab-store-sqlite/Cargo.toml crates/kebab-app/src/error_signal.rs
git commit -m "$(cat <<'EOF'
🏗️ feat(kebab-store-sqlite): add NotIndexed typed error (fb-27)
New `SqliteStore::open_existing` API + `NotIndexed` signal for the
missing-DB / schema-mismatch case. kebab-app re-exports the type via
its `error_signal` module so kebab-cli's `error_classify` can map it
to `error.v1 { code: "not_indexed" }`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: Define SchemaV1 struct + facade
Files:
-
Create:
crates/kebab-app/src/schema.rs -
Modify:
crates/kebab-app/src/lib.rs -
Step 1: Create
crates/kebab-app/src/schema.rs
//! `kebab schema` — introspection report. See spec
//! `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use kebab_config::Config;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaV1 {
pub kebab_version: String,
pub wire: WireBlock,
pub capabilities: Capabilities,
pub models: Models,
pub stats: Stats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireBlock {
pub schemas: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
pub json_mode: bool,
pub ingest_progress: bool,
pub ingest_cancellation: bool,
pub rag_multi_turn: bool,
pub search_cache: bool,
pub incremental_ingest: bool,
pub streaming_ask: bool,
pub http_daemon: bool,
pub mcp_server: bool,
pub single_file_ingest: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Models {
pub parser_version: String,
pub chunker_version: String,
pub embedding_version: String,
pub prompt_template_version: String,
pub index_version: String,
pub corpus_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stats {
pub doc_count: u64,
pub chunk_count: u64,
pub asset_count: u64,
pub last_ingest_at: Option<String>,
}
const KEBAB_VERSION: &str = env!("CARGO_PKG_VERSION");
const WIRE_SCHEMAS: &[&str] = &[
"answer.v1",
"search_hit.v1",
"doc_summary.v1",
"chunk_inspection.v1",
"doctor.v1",
"ingest_report.v1",
"ingest_progress.v1",
"reset_report.v1",
"citation.v1",
"schema.v1",
"error.v1",
];
pub fn schema_with_config(cfg: &Config) -> anyhow::Result<SchemaV1> {
let store = open_store_for_stats(cfg)?;
let stats = collect_stats(&store)?;
let models = collect_models(cfg, &store)?;
Ok(SchemaV1 {
kebab_version: KEBAB_VERSION.to_string(),
wire: WireBlock {
schemas: WIRE_SCHEMAS.iter().map(|s| (*s).to_string()).collect(),
},
capabilities: capabilities_snapshot(),
models,
stats,
})
}
fn capabilities_snapshot() -> Capabilities {
Capabilities {
json_mode: true,
ingest_progress: true,
ingest_cancellation: true,
rag_multi_turn: true,
search_cache: true,
incremental_ingest: true,
streaming_ask: false,
http_daemon: false,
mcp_server: false,
single_file_ingest: false,
}
}
// open_store_for_stats / collect_stats / collect_models implementation
// uses the existing kebab-app helpers for storage open + the
// kebab-store-sqlite COUNT queries. Inspect crates/kebab-app/src/lib.rs
// for the existing pattern (e.g. `list_docs_with_config` opens the store
// the same way) and mirror it here. The exact code is in Step 2 below.
- Step 2: Add the helper functions to
crates/kebab-app/src/schema.rs
After the pub fn schema_with_config definition, add:
fn open_store_for_stats(cfg: &Config) -> anyhow::Result<kebab_store_sqlite::SqliteStore> {
let data_dir = cfg.resolve_data_dir()?;
let db_path = data_dir.join("kebab.sqlite");
kebab_store_sqlite::SqliteStore::open_existing(&db_path)
}
fn collect_stats(store: &kebab_store_sqlite::SqliteStore) -> anyhow::Result<Stats> {
let counts = store.count_summary()?; // see Task 5 — adds this method
Ok(Stats {
doc_count: counts.doc_count,
chunk_count: counts.chunk_count,
asset_count: counts.asset_count,
last_ingest_at: counts.last_ingest_at,
})
}
fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> anyhow::Result<Models> {
Ok(Models {
parser_version: kebab_parse_md::PARSER_VERSION.to_string(),
chunker_version: cfg.chunking.chunker_version.clone(),
embedding_version: cfg.models.embedding.id.clone(),
prompt_template_version: cfg.rag.prompt_template_version.clone(),
index_version: kebab_store_vector::INDEX_VERSION_STR.to_string(),
corpus_revision: store.corpus_revision()?,
})
}
NOTE: The kebab_parse_md::PARSER_VERSION / kebab_store_vector::INDEX_VERSION_STR consts must be made pub. If they're currently private, add pub in their respective crates as part of this task. Run grep -n "PARSER_VERSION\|INDEX_VERSION" crates/kebab-parse-md/src crates/kebab-store-vector/src to locate them.
If the field path cfg.rag.prompt_template_version differs (the config schema stamps it under a different key), adjust accordingly — confirm by reading crates/kebab-config/src/lib.rs for the RagCfg struct.
If Config::resolve_data_dir does not exist, use the existing pattern from kebab_app::list_docs_with_config to derive the data_dir.
- Step 3: Wire schema module into
crates/kebab-app/src/lib.rs
Add pub mod schema; near the top of lib.rs (next to pub mod error_signal; from Task 1).
Add re-exports:
pub use schema::{
Capabilities, Models, SchemaV1, Stats, WireBlock, schema_with_config,
};
- Step 4: Verify compile
Run: cargo check -p kebab-app
Expected: PASS, OR fail with a missing API on SqliteStore::count_summary / corpus_revision. The latter is fine — corpus_revision already exists from p9-fb-19; count_summary is added in Task 5.
If corpus_revision() is missing, search for it: grep -rn "fn corpus_revision\|bump_corpus_revision" crates/kebab-store-sqlite/src/. It should exist from p9-fb-19. If not, stop — there's a deeper problem with the spec premise.
- Step 5: Commit (will not yet build until Task 5 — that's OK, intermediate state is acceptable for atomic feature work)
Hold off on commit until Task 5 makes things compile. Move directly to Task 5.
Task 5: Add count_summary to SqliteStore
Files:
-
Modify:
crates/kebab-store-sqlite/src/store.rs(or sibling) -
Test: same crate
-
Step 1: Write the failing test
Add at the bottom of crates/kebab-store-sqlite/src/store.rs (inside #[cfg(test)] mod tests):
#[test]
fn count_summary_zero_on_fresh_store() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("k.sqlite");
let store = SqliteStore::open(&p).unwrap();
let s = store.count_summary().unwrap();
assert_eq!(s.doc_count, 0);
assert_eq!(s.chunk_count, 0);
assert_eq!(s.asset_count, 0);
assert!(s.last_ingest_at.is_none());
}
- Step 2: Run test to verify it fails
Run: cargo test -p kebab-store-sqlite count_summary_zero -- --nocapture
Expected: FAIL — count_summary not found.
- Step 3: Add
CountSummarystruct + method
Add to crates/kebab-store-sqlite/src/store.rs:
#[derive(Debug, Clone)]
pub struct CountSummary {
pub doc_count: u64,
pub chunk_count: u64,
pub asset_count: u64,
pub last_ingest_at: Option<String>,
}
impl SqliteStore {
pub fn count_summary(&self) -> anyhow::Result<CountSummary> {
let conn = self.conn(); // or however the crate exposes its Connection
let doc_count: u64 = conn.query_row(
"SELECT COUNT(*) FROM documents", [], |r| r.get(0)
)?;
let chunk_count: u64 = conn.query_row(
"SELECT COUNT(*) FROM chunks", [], |r| r.get(0)
)?;
let asset_count: u64 = conn.query_row(
"SELECT COUNT(*) FROM assets", [], |r| r.get(0)
)?;
let last_ingest_at: Option<String> = conn.query_row(
"SELECT MAX(updated_at) FROM documents", [], |r| r.get(0)
).ok().flatten();
Ok(CountSummary { doc_count, chunk_count, asset_count, last_ingest_at })
}
}
The exact way to obtain the Connection (self.conn(), &self.pool, r2d2, etc.) depends on the existing crate structure. Inspect a similar method (e.g. how corpus_revision() reads from SQLite) and mirror that pattern. If the crate uses an internal with_conn(|c| ...) helper, use it.
- Step 4: Run test to verify pass
Run: cargo test -p kebab-store-sqlite count_summary_zero -- --nocapture
Expected: PASS.
- Step 5: Verify whole crate
Run: cargo test -p kebab-store-sqlite
Expected: PASS, no regressions.
- Step 6: Verify kebab-app now compiles
Run: cargo check -p kebab-app
Expected: PASS.
- Step 7: Commit
git add crates/kebab-store-sqlite/src/store.rs crates/kebab-app/src/schema.rs crates/kebab-app/src/lib.rs
git commit -m "$(cat <<'EOF'
✨ feat(kebab-app): schema_with_config facade (fb-27)
New `SchemaV1` struct + `schema_with_config(&Config)` builder. Surfaces
wire schemas list, capabilities (current + future placeholders), model
versions (parser/chunker/embedding/prompt_template/index/corpus_revision),
and stats (doc/chunk/asset counts + last ingest). kebab-store-sqlite
gains `count_summary()` to back the stats block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6: Integration test — schema_with_config end-to-end
Files:
-
Create:
crates/kebab-app/tests/schema_report.rs -
Step 1: Write the test
//! Integration test: kebab_app::schema_with_config returns a SchemaV1
//! that is internally consistent with a freshly-ingested TempDir KB.
use std::fs;
#[path = "common/mod.rs"]
mod common;
#[test]
fn schema_report_reflects_freshly_ingested_kb() {
let env = common::TestEnv::new();
fs::write(env.workspace_root.join("a.md"), "# A\n\nbody A.").unwrap();
fs::write(env.workspace_root.join("b.md"), "# B\n\nbody B.").unwrap();
let _report = kebab_app::ingest_with_config(&env.config(), false).unwrap();
let schema = kebab_app::schema_with_config(&env.config()).unwrap();
assert!(!schema.kebab_version.is_empty());
assert!(schema.wire.schemas.contains(&"schema.v1".to_string()));
assert!(schema.wire.schemas.contains(&"error.v1".to_string()));
assert!(schema.capabilities.json_mode);
assert!(!schema.capabilities.streaming_ask);
assert_eq!(schema.stats.doc_count, 2);
assert!(schema.stats.last_ingest_at.is_some());
}
#[test]
fn schema_report_on_empty_kb_has_zero_counts() {
let env = common::TestEnv::new();
// No ingest.
let schema = kebab_app::schema_with_config(&env.config()).unwrap();
assert_eq!(schema.stats.doc_count, 0);
assert_eq!(schema.stats.chunk_count, 0);
assert!(schema.stats.last_ingest_at.is_none());
}
The common::TestEnv helper is the pattern used by the rest of the kebab-app integration tests. Verify with ls crates/kebab-app/tests/common/ — if it does not exist, copy the helper inline (see crates/kebab-app/tests/ingest_lexical.rs for a working reference).
If a fresh, empty KB triggers NotIndexed because no kebab init has run, either:
-
Have the test call
kebab_app::init_workspace_with_config(&env.config(), false).unwrap()first, OR -
Make
schema_with_configresilient to missing DB by populating zero counts (preferred). Update the spec's "stats on empty KB" to clarify either behavior. Choose the init then schema pattern for the test; document it in the test comment. -
Step 2: Run test
Run: cargo test -p kebab-app --test schema_report
Expected: PASS.
If it fails because a fresh TempDir has no DB → init the workspace first in the test. Adjust as noted above.
- Step 3: Commit
git add crates/kebab-app/tests/schema_report.rs
git commit -m "$(cat <<'EOF'
🧪 test(kebab-app): schema_with_config integration coverage (fb-27)
Two scenarios: freshly-ingested 2-doc KB (stats reflect counts +
last_ingest_at populated) and empty-but-initialized KB (counts zero,
last_ingest_at None).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: Add wire_schema + wire_error_v1 helpers
Files:
-
Modify:
crates/kebab-cli/src/wire.rs -
Step 1: Write the failing tests
Add to the #[cfg(test)] mod tests block at the bottom of crates/kebab-cli/src/wire.rs:
#[test]
fn schema_wrapper_tags_schema_version() {
use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock};
let schema = SchemaV1 {
kebab_version: "0.2.1".to_string(),
wire: WireBlock { schemas: vec!["answer.v1".to_string()] },
capabilities: Capabilities {
json_mode: true, ingest_progress: true, ingest_cancellation: true,
rag_multi_turn: true, search_cache: true, incremental_ingest: true,
streaming_ask: false, http_daemon: false, mcp_server: false,
single_file_ingest: false,
},
models: Models {
parser_version: "x".to_string(),
chunker_version: "y".to_string(),
embedding_version: "z".to_string(),
prompt_template_version: "w".to_string(),
index_version: "v".to_string(),
corpus_revision: 7,
},
stats: Stats {
doc_count: 1, chunk_count: 2, asset_count: 1,
last_ingest_at: None,
},
};
let v = wire_schema(&schema);
assert_eq!(schema_of(&v), Some("schema.v1"));
assert_eq!(v.get("kebab_version").and_then(Value::as_str), Some("0.2.1"));
}
#[test]
fn error_wrapper_tags_schema_version_and_emits_code() {
use crate::error_classify::ErrorV1;
let err = ErrorV1 {
code: "config_invalid".to_string(),
message: "bad config".to_string(),
details: serde_json::json!({"path": "/tmp/x"}),
hint: Some("check the path".to_string()),
};
let v = wire_error_v1(&err);
assert_eq!(schema_of(&v), Some("error.v1"));
assert_eq!(v.get("code").and_then(Value::as_str), Some("config_invalid"));
}
- Step 2: Run test to verify it fails
Run: cargo test -p kebab-cli --lib wire::tests
Expected: FAIL — wire_schema / wire_error_v1 / ErrorV1 not defined.
- Step 3: Add helpers to
crates/kebab-cli/src/wire.rs
/// Wrap a [`SchemaV1`] as `schema.v1`.
pub fn wire_schema(s: &kebab_app::SchemaV1) -> Value {
let v = serde_json::to_value(s).expect("SchemaV1 serializes");
tag_object(v, "schema.v1")
}
/// Wrap an [`ErrorV1`] as `error.v1`.
pub fn wire_error_v1(e: &crate::error_classify::ErrorV1) -> Value {
let v = serde_json::to_value(e).expect("ErrorV1 serializes");
tag_object(v, "error.v1")
}
Tests will not yet pass because error_classify::ErrorV1 does not exist — Task 8 adds it. Hold off on the wire test run until Task 8.
- Step 4: Move on to Task 8 (no commit yet — wire helpers + classify ship together)
Task 8: Define ErrorV1 + classify function
Files:
-
Create:
crates/kebab-cli/src/error_classify.rs -
Modify:
crates/kebab-cli/src/main.rs(just addsmod error_classify;) -
Step 1: Create
crates/kebab-cli/src/error_classify.rs
//! Map `anyhow::Error` (returned by `kebab-app` facade calls) to the
//! `error.v1` wire shape. The classifier downcasts to known typed errors
//! re-exported via `kebab_app::error_signal` (LlmError, ConfigInvalid,
//! NotIndexed) and falls back to `code: "generic"` for everything else.
//!
//! Refusal / no-hit / doctor-unhealthy are NOT routed here — they remain
//! exit-code-only signals (see main.rs `exit_code()`).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use kebab_app::error_signal::{ConfigInvalid, LlmError, NotIndexed};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorV1 {
pub code: String,
pub message: String,
pub details: Value,
pub hint: Option<String>,
}
pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
if let Some(s) = err.downcast_ref::<ConfigInvalid>() {
return ErrorV1 {
code: "config_invalid".to_string(),
message: s.to_string(),
details: json!({
"path": s.path.to_string_lossy(),
"cause": s.cause,
}),
hint: Some("check `--config <path>` and TOML syntax".to_string()),
};
}
if let Some(s) = err.downcast_ref::<NotIndexed>() {
return ErrorV1 {
code: "not_indexed".to_string(),
message: s.to_string(),
details: json!({
"expected": s.expected,
"found": s.found,
}),
hint: Some("run `kebab init` then `kebab ingest`".to_string()),
};
}
if let Some(s) = err.downcast_ref::<LlmError>() {
return classify_llm(s);
}
if let Some(io) = err.downcast_ref::<std::io::Error>() {
return ErrorV1 {
code: "io_error".to_string(),
message: io.to_string(),
details: json!({"kind": format!("{:?}", io.kind())}),
hint: None,
};
}
let mut details = json!({});
if verbose {
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
details = json!({"chain": chain});
}
ErrorV1 {
code: "generic".to_string(),
message: err.to_string(),
details,
hint: None,
}
}
fn classify_llm(s: &LlmError) -> ErrorV1 {
match s {
LlmError::Unreachable { endpoint, source } => ErrorV1 {
code: "model_unreachable".to_string(),
message: format!("ollama unreachable at {endpoint}"),
details: json!({
"endpoint": endpoint,
"source": source.to_string(),
}),
hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")),
},
LlmError::ModelNotPulled(model) => ErrorV1 {
code: "model_not_pulled".to_string(),
message: format!("ollama model `{model}` is not pulled"),
details: json!({"model": model}),
hint: Some(format!("run `ollama pull {model}`")),
},
LlmError::Timeout(e) => ErrorV1 {
code: "timeout".to_string(),
message: format!("ollama timeout: {e}"),
details: json!({"source": e.to_string()}),
hint: Some("increase timeout or check Ollama load".to_string()),
},
LlmError::Stream(body) => ErrorV1 {
code: "generic".to_string(),
message: format!("ollama HTTP error: {body}"),
details: json!({"body": body}),
hint: None,
},
LlmError::Malformed(line) => ErrorV1 {
code: "generic".to_string(),
message: format!("malformed response line: {line}"),
details: json!({"line": line}),
hint: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_invalid_classifies_to_config_invalid_code() {
let err = anyhow::Error::new(ConfigInvalid {
path: std::path::PathBuf::from("/tmp/x.toml"),
cause: "missing".to_string(),
});
let v1 = classify(&err, false);
assert_eq!(v1.code, "config_invalid");
assert_eq!(v1.details.get("path").and_then(|p| p.as_str()), Some("/tmp/x.toml"));
assert!(v1.hint.is_some());
}
#[test]
fn not_indexed_classifies_correctly() {
let err = anyhow::Error::new(NotIndexed {
expected: "/data/k.sqlite".to_string(),
found: None,
});
let v1 = classify(&err, false);
assert_eq!(v1.code, "not_indexed");
}
#[test]
fn llm_unreachable_classifies_to_model_unreachable() {
// We cannot construct a reqwest::Error from scratch (private constructor).
// Use a real network call with a guaranteed-unroutable endpoint:
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(50))
.build().unwrap();
let err = client.get("http://127.0.0.1:1").send().unwrap_err();
let llm = LlmError::Unreachable {
endpoint: "http://127.0.0.1:1".to_string(),
source: err,
};
let anyhow_err = anyhow::Error::new(llm);
let v1 = classify(&anyhow_err, false);
assert_eq!(v1.code, "model_unreachable");
}
#[test]
fn model_not_pulled_classifies_correctly() {
let llm = LlmError::ModelNotPulled("gemma4:e4b".to_string());
let v1 = classify(&anyhow::Error::new(llm), false);
assert_eq!(v1.code, "model_not_pulled");
assert_eq!(v1.details.get("model").and_then(|p| p.as_str()), Some("gemma4:e4b"));
}
#[test]
fn unknown_error_classifies_to_generic() {
let err = anyhow::anyhow!("something else");
let v1 = classify(&err, false);
assert_eq!(v1.code, "generic");
assert!(v1.hint.is_none());
}
#[test]
fn generic_with_verbose_includes_chain() {
let err = anyhow::anyhow!("root").context("middle").context("leaf");
let v1 = classify(&err, true);
assert_eq!(v1.code, "generic");
let chain = v1.details.get("chain").and_then(|c| c.as_array()).unwrap();
assert_eq!(chain.len(), 3);
}
#[test]
fn io_error_classifies_correctly() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let err = anyhow::Error::new(io);
let v1 = classify(&err, false);
assert_eq!(v1.code, "io_error");
}
}
If reqwest is not already a dev-dependency of kebab-cli, add it to [dev-dependencies] in crates/kebab-cli/Cargo.toml (using the workspace dep).
- Step 2: Register the module in
crates/kebab-cli/src/main.rs
At the top of main.rs (alongside other mod declarations), add:
mod error_classify;
- Step 3: Run tests
Run: cargo test -p kebab-cli --lib error_classify::tests
Expected: PASS (all 7 tests).
Run: cargo test -p kebab-cli --lib wire::tests
Expected: PASS (the schema/error wire tests added in Task 7 now pass).
- Step 4: Commit
git add crates/kebab-cli/src/error_classify.rs crates/kebab-cli/src/main.rs crates/kebab-cli/src/wire.rs crates/kebab-cli/Cargo.toml
git commit -m "$(cat <<'EOF'
✨ feat(kebab-cli): error_classify + wire_error_v1 (fb-27)
Maps anyhow chain → ErrorV1 wire record by downcasting to known typed
errors (LlmError / ConfigInvalid / NotIndexed / std::io::Error). Generic
fallback emits `code: "generic"` with the chain in `details` when
verbose. wire.rs adds wire_schema / wire_error_v1 wrappers consistent
with the existing tag_object pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: Cmd::Schema CLI subcommand
Files:
-
Modify:
crates/kebab-cli/src/main.rs -
Step 1: Add the subcommand variant
Find the enum Cmd definition in crates/kebab-cli/src/main.rs. Add a new variant:
/// Print introspection report (wire schemas, capabilities, model versions, stats).
Schema,
- Step 2: Wire the arm in
fn run
Inside fn run(cli: &Cli) -> anyhow::Result<()>, in the match &cli.command block, add a new arm:
Cmd::Schema => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let report = kebab_app::schema_with_config(&cfg)?;
if cli.json {
let v = wire::wire_schema(&report);
println!("{}", serde_json::to_string(&v)?);
} else {
print_schema_text(&report);
}
Ok(())
}
- Step 3: Add the human-friendly text printer
Add to crates/kebab-cli/src/main.rs (near other helpers, e.g. after fn exit_code):
fn print_schema_text(s: &kebab_app::SchemaV1) {
println!("kebab v{}\n", s.kebab_version);
println!("wire schemas");
println!(" {}", s.wire.schemas.join(", "));
println!();
println!("capabilities");
let caps = [
("json_mode", s.capabilities.json_mode),
("ingest_progress", s.capabilities.ingest_progress),
("ingest_cancellation", s.capabilities.ingest_cancellation),
("rag_multi_turn", s.capabilities.rag_multi_turn),
("search_cache", s.capabilities.search_cache),
("incremental_ingest", s.capabilities.incremental_ingest),
("streaming_ask", s.capabilities.streaming_ask),
("http_daemon", s.capabilities.http_daemon),
("mcp_server", s.capabilities.mcp_server),
("single_file_ingest", s.capabilities.single_file_ingest),
];
for (name, on) in caps {
let mark = if on { "✓" } else { "✗" };
println!(" {mark} {name}");
}
println!();
println!("models");
println!(" parser_version {}", s.models.parser_version);
println!(" chunker_version {}", s.models.chunker_version);
println!(" embedding_version {}", s.models.embedding_version);
println!(" prompt_template_version {}", s.models.prompt_template_version);
println!(" index_version {}", s.models.index_version);
println!(" corpus_revision {}", s.models.corpus_revision);
println!();
println!("stats");
println!(" doc_count {}", s.stats.doc_count);
println!(" chunk_count {}", s.stats.chunk_count);
println!(" asset_count {}", s.stats.asset_count);
let last = s.stats.last_ingest_at.as_deref().unwrap_or("(never)");
println!(" last_ingest_at {last}");
}
- Step 4: Smoke check — build the binary
Run: cargo build -p kebab-cli
Expected: PASS.
Run: target/debug/kebab schema --help 2>&1 | head -5
Expected: shows the schema subcommand help.
- Step 5: Manual smoke against /tmp
mkdir -p /tmp/kebab-fb27-smoke
cat > /tmp/kebab-fb27-smoke/config.toml <<'EOF'
[workspace]
root = "/tmp/kebab-fb27-smoke/notes"
[storage]
data_dir = "/tmp/kebab-fb27-smoke/data"
[models.embedding]
id = "fastembed-mle5small-384"
EOF
mkdir -p /tmp/kebab-fb27-smoke/notes
target/debug/kebab --config /tmp/kebab-fb27-smoke/config.toml init --force
target/debug/kebab --config /tmp/kebab-fb27-smoke/config.toml schema
target/debug/kebab --config /tmp/kebab-fb27-smoke/config.toml --json schema | jq .
Expected: text output shows the layout from Task 5; JSON output is well-formed and contains schema_version: "schema.v1".
- Step 6: Commit
git add crates/kebab-cli/src/main.rs
git commit -m "$(cat <<'EOF'
✨ feat(kebab-cli): kebab schema subcommand (fb-27)
Text mode: doctor-style key/value layout. JSON mode: schema.v1 wire
record. Honors `--config <path>` via the established
`kebab_app::schema_with_config(&cfg)` facade pattern (per the P3-5 /
P4-3 regression conventions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 10: Replace Err(e) arm in main() with json-mode classify branch
Files:
-
Modify:
crates/kebab-cli/src/main.rs -
Step 1: Locate the existing main() Err arm
Search: grep -n "fn main\|exit_code\|cli.json" crates/kebab-cli/src/main.rs | head -20. Find the match run(&cli) block. The current Err(e) arm prints to stderr.
- Step 2: Replace it with the json-aware branch
Edit the Err(e) arm to look like this:
Err(e) => {
let code = exit_code(&e);
if code != 1 {
if cli.json {
let v1 = error_classify::classify(&e, cli.verbose);
let v = wire::wire_error_v1(&v1);
eprintln!("{}", serde_json::to_string(&v).unwrap_or_else(|_| {
"{\"schema_version\":\"error.v1\",\"code\":\"generic\",\"message\":\"serialize failed\"}".to_string()
}));
} else {
eprintln!("error: {e}");
if cli.verbose {
for cause in e.chain().skip(1) {
eprintln!(" caused by: {cause}");
}
}
}
}
ExitCode::from(code)
}
The existing branch already has the non-JSON form — just wrap it in the cli.json if/else.
- Step 3: Build + smoke
Run: cargo build -p kebab-cli
Expected: PASS.
target/debug/kebab --json --config /nonexistent ingest 2>&1 1>/dev/null | jq .
Expected: stderr contains a single ndjson line; jq . parses it; .schema_version == "error.v1"; .code == "config_invalid".
target/debug/kebab --config /nonexistent ingest 2>&1 1>/dev/null
Expected: stderr shows the legacy text form (error: config invalid at /nonexistent: read failed: ...).
- Step 4: Commit
git add crates/kebab-cli/src/main.rs
git commit -m "$(cat <<'EOF'
✨ feat(kebab-cli): emit error.v1 ndjson on stderr in --json mode (fb-27)
Wraps the existing `Err(e)` arm with a `cli.json` branch:
- `--json`: stderr ndjson `error.v1` via wire_error_v1
- non-`--json`: legacy `error: <msg>` text path (unchanged)
exit_code() unchanged — RefusalSignal/NoHitSignal/DoctorUnhealthy
still drive 1/1/3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 11: Integration test — kebab schema --json end-to-end
Files:
-
Create:
crates/kebab-cli/tests/cli_schema.rs -
Step 1: Write the test
//! Integration: spawn the kebab binary and parse `kebab schema --json`.
use std::process::Command;
#[path = "common/mod.rs"]
mod common;
#[test]
fn cli_schema_json_emits_schema_v1() {
let env = common::CliEnv::new();
env.run(&["init", "--force"]).success();
let out = env.run(&["--json", "schema"]).success().stdout();
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("schema.v1"));
assert!(v.get("kebab_version").and_then(|s| s.as_str()).unwrap().len() > 0);
let caps = v.get("capabilities").unwrap().as_object().unwrap();
assert_eq!(caps.get("json_mode").and_then(|b| b.as_bool()), Some(true));
assert_eq!(caps.get("mcp_server").and_then(|b| b.as_bool()), Some(false));
}
#[test]
fn cli_schema_text_mode_runs() {
let env = common::CliEnv::new();
env.run(&["init", "--force"]).success();
let out = env.run(&["schema"]).success().stdout();
assert!(out.contains("kebab v"));
assert!(out.contains("capabilities"));
assert!(out.contains("models"));
assert!(out.contains("stats"));
}
common::CliEnv is the existing test harness for kebab-cli integration tests. Inspect crates/kebab-cli/tests/common/mod.rs to confirm the API; if run().success().stdout() differs (e.g. the helper returns an assert_cmd::Output), adapt the calls. If the harness does not exist, write a minimal one inline using std::process::Command.
- Step 2: Run test
Run: cargo test -p kebab-cli --test cli_schema
Expected: PASS.
- Step 3: Commit
git add crates/kebab-cli/tests/cli_schema.rs
git commit -m "🧪 test(kebab-cli): integration coverage for kebab schema (fb-27)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 12: Integration test — error.v1 emission on stderr
Files:
-
Create:
crates/kebab-cli/tests/cli_error_wire.rs -
Step 1: Write the test
//! Integration: spawn kebab and verify --json mode emits error.v1 ndjson
//! on stderr while non-json mode emits legacy text.
#[path = "common/mod.rs"]
mod common;
#[test]
fn json_mode_emits_error_v1_on_config_missing() {
let env = common::CliEnv::new();
let out = env
.raw_args(&["--json", "--config", "/this/does/not/exist", "ingest"])
.run_expect_failure();
assert_eq!(out.exit_code, 2);
let stderr_line = out.stderr.lines().next().expect("stderr has a line");
let v: serde_json::Value = serde_json::from_str(stderr_line)
.expect("stderr first line is JSON");
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("error.v1"));
assert_eq!(v.get("code").and_then(|s| s.as_str()), Some("config_invalid"));
}
#[test]
fn text_mode_emits_legacy_error_format() {
let env = common::CliEnv::new();
let out = env
.raw_args(&["--config", "/this/does/not/exist", "ingest"])
.run_expect_failure();
assert_eq!(out.exit_code, 2);
assert!(out.stderr.starts_with("error:"));
// Verify it does NOT look like JSON — no leading `{`.
assert!(!out.stderr.trim_start().starts_with('{'));
}
Adapt raw_args / run_expect_failure to the existing common::CliEnv API. If the API is different, mirror the patterns from existing tests like cli_ingest_progress.rs or similar.
- Step 2: Run test
Run: cargo test -p kebab-cli --test cli_error_wire
Expected: PASS.
- Step 3: Commit
git add crates/kebab-cli/tests/cli_error_wire.rs
git commit -m "🧪 test(kebab-cli): integration coverage for error.v1 (fb-27)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 13: JSON Schema literal — schema.v1
Files:
-
Create:
docs/wire-schema/v1/schema.schema.json -
Step 1: Write the schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://kebab.local/wire-schema/v1/schema.schema.json",
"title": "schema.v1",
"description": "kebab introspection report — wire schemas, capabilities, model versions, and index stats.",
"type": "object",
"required": ["schema_version", "kebab_version", "wire", "capabilities", "models", "stats"],
"properties": {
"schema_version": { "const": "schema.v1" },
"kebab_version": { "type": "string" },
"wire": {
"type": "object",
"required": ["schemas"],
"properties": {
"schemas": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z_]+\\.v[0-9]+$" }
}
}
},
"capabilities": {
"type": "object",
"additionalProperties": { "type": "boolean" },
"required": [
"json_mode", "ingest_progress", "ingest_cancellation",
"rag_multi_turn", "search_cache", "incremental_ingest",
"streaming_ask", "http_daemon", "mcp_server", "single_file_ingest"
]
},
"models": {
"type": "object",
"required": [
"parser_version", "chunker_version", "embedding_version",
"prompt_template_version", "index_version", "corpus_revision"
],
"properties": {
"parser_version": { "type": "string" },
"chunker_version": { "type": "string" },
"embedding_version": { "type": "string" },
"prompt_template_version": { "type": "string" },
"index_version": { "type": "string" },
"corpus_revision": { "type": "integer", "minimum": 0 }
}
},
"stats": {
"type": "object",
"required": ["doc_count", "chunk_count", "asset_count", "last_ingest_at"],
"properties": {
"doc_count": { "type": "integer", "minimum": 0 },
"chunk_count": { "type": "integer", "minimum": 0 },
"asset_count": { "type": "integer", "minimum": 0 },
"last_ingest_at": {
"anyOf": [
{ "type": "string", "format": "date-time" },
{ "type": "null" }
]
}
}
}
}
}
- Step 2: Validate it parses
Run: python3 -c "import json; json.load(open('docs/wire-schema/v1/schema.schema.json'))"
Expected: no output (valid JSON).
- Step 3: Commit
git add docs/wire-schema/v1/schema.schema.json
git commit -m "📝 docs(wire-schema): schema.v1 JSON Schema (fb-27)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 14: JSON Schema literal — error.v1
Files:
-
Create:
docs/wire-schema/v1/error.schema.json -
Step 1: Write the schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://kebab.local/wire-schema/v1/error.schema.json",
"title": "error.v1",
"description": "Structured fatal error emitted on stderr in --json mode.",
"type": "object",
"required": ["schema_version", "code", "message", "details"],
"properties": {
"schema_version": { "const": "error.v1" },
"code": {
"type": "string",
"enum": [
"config_invalid",
"not_indexed",
"model_unreachable",
"model_not_pulled",
"timeout",
"io_error",
"generic"
]
},
"message": { "type": "string" },
"details": { "type": "object" },
"hint": {
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
}
}
}
- Step 2: Validate
Run: python3 -c "import json; json.load(open('docs/wire-schema/v1/error.schema.json'))"
Expected: no output.
- Step 3: Commit
git add docs/wire-schema/v1/error.schema.json
git commit -m "📝 docs(wire-schema): error.v1 JSON Schema (fb-27)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 15: Doc sync — README, HANDOFF, CLAUDE.md, integrations skill
Files:
-
Modify:
README.md -
Modify:
HANDOFF.md -
Modify:
CLAUDE.md -
Modify:
integrations/claude-code/kebab/SKILL.md -
Step 1: README.md — add
kebab schemarow to commands table
Find the 명령 (commands) table in README.md. Add a row describing kebab schema:
| `kebab schema` | introspection (wire schemas / capabilities / models / stats); `--json` for `schema.v1` wire |
The exact column structure depends on the table — match the surrounding rows.
If there's a Configuration / wire schema reference section, add schema.v1 and error.v1 to the list.
- Step 2: HANDOFF.md — add one-line entry
In the ## 머지 후 발견된 버그 / 결정 (요약) section, add (date as appropriate):
- **2026-05-?? P9 post-도그푸딩 (p9-fb-27)** — `kebab schema [--json]` introspection 명령 + `error.v1` wire 도입. 정적 (wire schemas / capabilities / models) + 동적 (stats) 한 번에. `--json` 모드에서 fatal error 가 stderr ndjson 으로 emit (비 `--json` 은 기존 stderr text 유지). exit code 0/1/2/3 unchanged — `code` 필드가 fine-grained 분기. fb-30 MCP `initialize` capability matrix 의 prerequisite. spec: `tasks/p9/p9-fb-27-introspection-and-error-wire.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
- Step 3: CLAUDE.md — wire schema list update
Find the "Wire schema v1" section. Add schema.v1 and error.v1 to the wire schema enumeration. Mention that --json mode now emits error.v1 on stderr for fatal errors.
- Step 4: integrations/claude-code/kebab/SKILL.md — additive note
Add a sentence to the description / usage section noting that the skill can call kebab --json schema for capability discovery (gates streaming / multi-turn / etc. based on capabilities.*). Don't require it — keep additive.
- Step 5: design doc — §10 capability matrix subsection
Edit docs/superpowers/specs/2026-04-27-kebab-final-form-design.md. Find §10 (line 1372 baseline). Add a subsection (after the existing exit-code table, before §11):
### 10.1 Capability matrix + introspection (fb-27)
`kebab schema [--json]` 가 binary 의 capability set 을 노출한다.
`schema.v1` wire schema 가 `wire.schemas` (지원 wire id 목록), `capabilities`
(bool flag, 미래 surface 의 placeholder 도 항상 포함), `models` (cascade
version 6축), `stats` (doc/chunk/asset count + last_ingest_at) 를 한 호출로 반환한다.
`error.v1` wire schema 가 `--json` 모드에서 fatal error 를 stderr ndjson 으로
emit. code 7개 initial set: `config_invalid` / `not_indexed` /
`model_unreachable` / `model_not_pulled` / `timeout` / `io_error` /
`generic`. exit code 0/1/2/3 unchanged — `error.v1.code` 가 fine-grained
agent 분기 source.
- Step 6: Commit
git add README.md HANDOFF.md CLAUDE.md integrations/claude-code/kebab/SKILL.md docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
git commit -m "$(cat <<'EOF'
📝 docs: sync README / HANDOFF / CLAUDE / skill / design for fb-27
- README 명령 표 에 `kebab schema` 추가
- HANDOFF post-도그푸딩 항목 한 줄
- CLAUDE.md wire schema 절 schema.v1 / error.v1 추가
- integrations skill — schema 활용 안내 (additive)
- design §10.1 capability matrix subsection 신설
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 16: HOTFIXES entry + task spec status flip
Files:
-
Modify:
tasks/HOTFIXES.md -
Modify:
tasks/p9/p9-fb-27-introspection-and-error-wire.md -
Step 1: Add HOTFIXES entry
Insert a new dated entry at the top of tasks/HOTFIXES.md (right after the # Post-merge hotfixes log header, before the most recent existing entry):
## 2026-05-?? — p9-fb-27 (post-dogfooding): introspection (`kebab schema`) + structured error wire
**Source feedback**: 사용자 도그푸딩 2026-05-06 — agent 가 kebab 인스턴스의 wire 버전 / 기능 / 모델 / 인덱스 통계 introspect 못 함; error 가 stderr text 라 substring 분기 필요.
**Live binding 변경**:
- 신규 명령 `kebab schema [--json]` — text / `schema.v1` JSON. `--config <path>` honor.
- 신규 wire `schema.v1` — `kebab_version` / `wire.schemas` / `capabilities` (10 bool, 4 미래 surface 포함) / `models` (parser/chunker/embedding/prompt_template/index/corpus_revision 6축) / `stats` (doc/chunk/asset count + last_ingest_at).
- 신규 wire `error.v1` — `--json` 모드에서 fatal error 가 stderr ndjson 으로 emit. 비 `--json` 은 기존 stderr text 유지.
- error code 7개 initial set: `config_invalid` (`ConfigInvalid` signal in kebab-config) / `not_indexed` (`NotIndexed` in kebab-store-sqlite, `SqliteStore::open_existing` API 신규) / `model_unreachable` (`LlmError::Unreachable`) / `model_not_pulled` (`LlmError::ModelNotPulled`) / `timeout` (`LlmError::Timeout`) / `io_error` (`std::io::Error` chain detection) / `generic` (catch-all, verbose 시 `details.chain` 채움).
- exit code 0/1/2/3 unchanged — `RefusalSignal` / `NoHitSignal` / `DoctorUnhealthy` 만 보고 1/1/3 결정. 신규 5 signal 모두 fall-through → 2.
- `kebab-app::error_signal` 모듈 신규 — `doctor_signal` 과 신규 typed error 들 한 곳에서 re-export.
- `kebab-store-sqlite::SqliteStore::count_summary` 메서드 신규 — `schema.v1.stats` block backing.
**Spec contract impact**: design §10 에 §10.1 capability matrix subsection 추가 — `schema.v1` / `error.v1` wire 명시.
**Tests added**: kebab-config fb27_tests (2: ConfigInvalid downcast / malformed TOML), kebab-store-sqlite (2: NotIndexed signal + count_summary zero state), kebab-cli error_classify::tests (7: 7 code 분류 + verbose chain), kebab-cli wire::tests (2: schema.v1 / error.v1 round-trip), kebab-app schema_report integration (2: ingested + empty), kebab-cli cli_schema integration (2: --json + text), kebab-cli cli_error_wire integration (2: --json error.v1 + legacy text).
**Known limitation (deferred)**:
- `IoFailure` typed signal 도입 안 함 — `std::io::Error` chain detection 으로 충분. 발생지가 새 typed signal 필요해지면 case-by-case.
- `OpTimeout` 별 typed signal 도입 안 함 — 현재 `LlmError::Timeout` 하나로 충분 (LLM stream). embed batch / vector upsert timeout 이 별도로 surface 되면 후속 task.
- error code 확장 (예 `embedding_dim_mismatch`, `daemon_locked`, `mcp_protocol_error`) — 발생지 추가 시점 case-by-case (additive, error.v1 major bump 불필요).
- README / claude-code skill 의 `kebab schema` 사용 예시 확장 — 본 항목은 skill description 한 줄만, 본격 활용 가이드는 fb-30 MCP 머지 시점에 동시 갱신.
- Step 2: Flip task spec status
Edit tasks/p9/p9-fb-27-introspection-and-error-wire.md. Change the frontmatter line:
status: open
to:
status: completed
Also update the warning banner at the top — change the wording from "백로그 only — 미구현" to a "구현 완료. 본 spec 은 구현 시점의 frozen 상태이며, post-merge deviation 은 HOTFIXES.md 의 2026-05-?? — p9-fb-27 항목 참조." line.
- Step 3: Commit
git add tasks/HOTFIXES.md tasks/p9/p9-fb-27-introspection-and-error-wire.md
git commit -m "$(cat <<'EOF'
📝 docs(tasks): HOTFIXES entry + p9-fb-27 status → completed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 17: Final workspace verification
Files: None modified — verification only.
- Step 1: Workspace clippy
Run: cargo clippy --workspace --all-targets -- -D warnings
Expected: PASS, zero warnings.
- Step 2: Workspace test (single-thread linker)
Run: cargo test --workspace --no-fail-fast -j 1
Expected: PASS. (Per CLAUDE.md, the -j 1 is required to keep the linker from being SIGKILL'd.)
This will take 10-20 minutes on a fresh build. Don't skip it.
- Step 3: Manual smoke against /tmp
# Fresh smoke workspace.
rm -rf /tmp/kebab-fb27-final
mkdir -p /tmp/kebab-fb27-final/notes /tmp/kebab-fb27-final/data
cat > /tmp/kebab-fb27-final/config.toml <<'EOF'
[workspace]
root = "/tmp/kebab-fb27-final/notes"
[storage]
data_dir = "/tmp/kebab-fb27-final/data"
[models.embedding]
id = "fastembed-mle5small-384"
EOF
echo "# A\n\nbody A" > /tmp/kebab-fb27-final/notes/a.md
echo "# B\n\nbody B" > /tmp/kebab-fb27-final/notes/b.md
target/debug/kebab --config /tmp/kebab-fb27-final/config.toml init --force
target/debug/kebab --config /tmp/kebab-fb27-final/config.toml ingest
echo "== text mode =="
target/debug/kebab --config /tmp/kebab-fb27-final/config.toml schema
echo "== json mode =="
target/debug/kebab --config /tmp/kebab-fb27-final/config.toml --json schema | jq .
echo "== error wire (config missing, --json) =="
target/debug/kebab --json --config /nonexistent ingest 2>&1 1>/dev/null | jq .
echo "== legacy error (config missing, no --json) =="
target/debug/kebab --config /nonexistent ingest 2>&1 1>/dev/null
Expected:
-
text mode shows the 4-section layout
-
json mode shows
schema_version: "schema.v1"+stats.doc_count: 2 -
error wire shows
schema_version: "error.v1"+code: "config_invalid" -
legacy error shows
error: config invalid at /nonexistent: ... -
Step 4: If all 3 above pass, this task is the final commit point
There is nothing to commit at this step — the verification confirms prior commits.
If something failed: fix it as a new commit on the same branch (do not amend) and re-run the verification.
Self-review checklist (run after Task 17)
After all tasks land, sweep the spec one more time:
- Spec section 1 (
kebab schema [--json]) — Tasks 4, 5, 9, 11, 13. ✅ - Spec section 2 (
error.v1wire) — Tasks 7, 8, 10, 12, 14. ✅ - Spec section 3 (Error code catalog 7 codes) — Task 8. ✅
- Spec section 4 (
kebab-app::error_signal+error_classify) — Tasks 1–3, 7, 8. ✅ - Spec section 5 (Testing 7 layers) — Tasks 2, 3, 5, 6, 8, 11, 12. ✅
- Spec section 6 (Migration / sync) — Tasks 13, 14, 15, 16. ✅
- Final workspace verification — Task 17. ✅
If any spec requirement is not covered by a task, add the missing task before declaring the plan ready.