From c20da0f2744f4ea2c08590cc1533ef3f2ef5ae30 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 11:12:05 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20docs(plan):=20p9-fb-27=20impleme?= =?UTF-8?q?ntation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...7-p9-fb-27-introspection-and-error-wire.md | 1727 +++++++++++++++++ 1 file changed, 1727 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-p9-fb-27-introspection-and-error-wire.md diff --git a/docs/superpowers/plans/2026-05-07-p9-fb-27-introspection-and-error-wire.md b/docs/superpowers/plans/2026-05-07-p9-fb-27-introspection-and-error-wire.md new file mode 100644 index 0000000..da51788 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-p9-fb-27-introspection-and-error-wire.md @@ -0,0 +1,1727 @@ +# 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 definitions +- `crates/kebab-app/src/schema.rs` — SchemaV1 struct + schema_with_config facade +- `crates/kebab-cli/src/error_classify.rs` — anyhow::Error → ErrorV1 dispatcher +- `crates/kebab-app/tests/schema_report.rs` — integration test for facade +- `crates/kebab-cli/tests/cli_schema.rs` — binary spawn test for `kebab schema --json` +- `crates/kebab-cli/tests/cli_error_wire.rs` — binary spawn test for error.v1 emission +- `docs/wire-schema/v1/schema.schema.json` — JSON Schema literal for schema.v1 +- `docs/wire-schema/v1/error.schema.json` — JSON Schema literal for error.v1 + +**Modify:** +- `crates/kebab-app/src/lib.rs` — add `pub mod error_signal;` + `pub mod schema;` + re-export `SchemaV1`, `schema_with_config` +- `crates/kebab-config/src/lib.rs` — add `ConfigInvalid` typed error + wrap `from_file` errors +- `crates/kebab-store-sqlite/src/store.rs` — add `NotIndexed` typed error + wrap missing-DB / migration paths +- `crates/kebab-cli/src/main.rs` — add `Cmd::Schema` arm, replace `Err(e)` arm with json-mode classify branch, register new module +- `crates/kebab-cli/src/wire.rs` — add `wire_schema` + `wire_error_v1` helpers +- `tasks/p9/p9-fb-27-introspection-and-error-wire.md` — flip `status: open` → `completed` +- `tasks/HOTFIXES.md` — add `2026-05-?? — fb-27` entry +- `HANDOFF.md` — add one-line entry under "머지 후 발견된 결정" +- `README.md` — add `kebab schema` row to 명령 table +- `CLAUDE.md` — add `schema.v1` / `error.v1` to wire schema list +- `integrations/claude-code/kebab/SKILL.md` — additive note about `kebab schema` for capability discovery +- `docs/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`** + +```rust +//! 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: + +```rust +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: + +```rust +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** + +```bash +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) +EOF +)" +``` + +--- + +## Task 2: Add `ConfigInvalid` typed error to kebab-config + +**Files:** +- Modify: `crates/kebab-config/src/lib.rs` +- Test: same file (`#[cfg(test)] mod tests` or 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): + +```rust +#[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::() + .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::() + .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: + +```rust +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::()` 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`: + +```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` in `crates/kebab-config/src/lib.rs`. Modify it so every `Err` branch wraps the underlying error in `ConfigInvalid`. Example pattern: + +```rust +pub fn from_file(path: &Path) -> anyhow::Result { + 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** + +```bash +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::()` +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) +EOF +)" +``` + +--- + +## Task 3: Add `NotIndexed` typed error to kebab-store-sqlite + +**Files:** +- Modify: `crates/kebab-store-sqlite/src/store.rs` (or the file containing `SqliteStore::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): + +```rust +#[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::() + .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 `NotIndexed` and the open_existing API** + +Add to `crates/kebab-store-sqlite/src/store.rs` (top of file, near other type definitions): + +```rust +/// 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, +} +``` + +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: + +```rust +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 { + 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** + +```bash +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) +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`** + +```rust +//! `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, +} + +#[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, +} + +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 { + 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: + +```rust +fn open_store_for_stats(cfg: &Config) -> anyhow::Result { + 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 { + 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 { + 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: +```rust +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`): + +```rust +#[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 `CountSummary` struct + method** + +Add to `crates/kebab-store-sqlite/src/store.rs`: + +```rust +#[derive(Debug, Clone)] +pub struct CountSummary { + pub doc_count: u64, + pub chunk_count: u64, + pub asset_count: u64, + pub last_ingest_at: Option, +} + +impl SqliteStore { + pub fn count_summary(&self) -> anyhow::Result { + 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 = 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** + +```bash +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) +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** + +```rust +//! 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_config` resilient 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** + +```bash +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) +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`: + +```rust +#[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`** + +```rust +/// 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 adds `mod error_classify;`) + +- [ ] **Step 1: Create `crates/kebab-cli/src/error_classify.rs`** + +```rust +//! 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, +} + +pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { + if let Some(s) = err.downcast_ref::() { + 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 ` and TOML syntax".to_string()), + }; + } + if let Some(s) = err.downcast_ref::() { + 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::() { + return classify_llm(s); + } + if let Some(io) = err.downcast_ref::() { + 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 = 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: + +```rust +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** + +```bash +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) +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: + +```rust +/// 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: + +```rust +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`): + +```rust +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** + +```bash +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** + +```bash +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 ` 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) +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: + +```rust +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. + +```bash +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"`. + +```bash +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** + +```bash +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: ` text path (unchanged) + +exit_code() unchanged — RefusalSignal/NoHitSignal/DoctorUnhealthy +still drive 1/1/3. + +Co-Authored-By: Claude Opus 4.7 (1M context) +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** + +```rust +//! 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** + +```bash +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) " +``` + +--- + +## Task 12: Integration test — error.v1 emission on stderr + +**Files:** +- Create: `crates/kebab-cli/tests/cli_error_wire.rs` + +- [ ] **Step 1: Write the test** + +```rust +//! 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** + +```bash +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) " +``` + +--- + +## Task 13: JSON Schema literal — `schema.v1` + +**Files:** +- Create: `docs/wire-schema/v1/schema.schema.json` + +- [ ] **Step 1: Write the schema** + +```json +{ + "$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** + +```bash +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) " +``` + +--- + +## Task 14: JSON Schema literal — `error.v1` + +**Files:** +- Create: `docs/wire-schema/v1/error.schema.json` + +- [ ] **Step 1: Write the schema** + +```json +{ + "$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** + +```bash +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) " +``` + +--- + +## 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 schema` row to commands table** + +Find the 명령 (commands) table in `README.md`. Add a row describing `kebab schema`: + +```markdown +| `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): + +```markdown +- **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): + +```markdown +### 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** + +```bash +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) +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): + +```markdown +## 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 ` 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: + +```yaml +status: open +``` + +to: + +```yaml +status: completed +``` + +Also update the warning banner at the top — change the wording from "백로그 only — 미구현" to a "구현 완료. 본 spec 은 구현 시점의 frozen 상태이며, post-merge deviation 은 [HOTFIXES.md](../../tasks/HOTFIXES.md) 의 2026-05-?? — p9-fb-27 항목 참조." line. + +- [ ] **Step 3: Commit** + +```bash +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) +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** + +```bash +# 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.v1` wire) — 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.