From fb31befef19b8726dd836627a4864521480300bf Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sun, 10 May 2026 12:14:26 +0900 Subject: [PATCH] plan(fb-37): trace + stats implementation plan 10 tasks: kebab-core types, store breakdowns/index_bytes helpers, extended CountSummary + Stats wire mirror, HybridRetriever search_with_trace, App SearchResponse.trace threading, CLI --trace flag, integration tests, MCP SearchInput.trace, TUI TracePopup, docs (wire schema + README + SMOKE + INDEX + SKILL). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-10-p9-fb-37-trace-and-stats.md | 2036 +++++++++++++++++ 1 file changed, 2036 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md diff --git a/docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md b/docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md new file mode 100644 index 0000000..7ec475e --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md @@ -0,0 +1,2036 @@ +# fb-37 Trace + Stats Implementation Plan + +> **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:** Surface retrieval pipeline trace (`kebab search Q --trace`) and richer KB stats (`kebab schema --json`) for agent / user debugging. + +**Architecture:** Two additive surfaces. Trace = optional `trace` field on `search_response.v1` populated when `SearchOpts.trace = true`; HybridRetriever exposes a parallel `search_with_trace` method capturing pre-fusion lex/vec lists + per-stage timing. Stats = four new fields (`media_breakdown` / `lang_breakdown` / `index_bytes` / `stale_doc_count`) on existing `schema.v1.stats` populated unconditionally by new SQLite GROUP BY + fs::metadata helpers. TUI search pane gains `t` keystroke that re-runs the query with trace and opens a popup. + +**Tech Stack:** Rust 2024, rusqlite (SQLite WHERE / GROUP BY / json_type / json_extract / json_each), std::time::Instant, std::fs, serde, ratatui. + +**Spec:** `docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md` + +--- + +## File map + +**Create:** +- `crates/kebab-search/src/trace.rs` — trace timing + capture helpers (kept separate from `hybrid.rs` so `hybrid.rs` stays focused) +- `crates/kebab-store-sqlite/src/stats_ext.rs` — `breakdowns()` + `index_bytes()` helpers +- `crates/kebab-tui/src/trace_popup.rs` — TUI popup widget + state +- `crates/kebab-cli/tests/wire_search_trace.rs` — `--trace` integration tests +- `crates/kebab-cli/tests/wire_schema_breakdowns.rs` — `kebab schema` extended stats integration tests +- `crates/kebab-mcp/tests/tools_call_search_trace.rs` — MCP search trace integration test + +**Modify:** +- `crates/kebab-core/src/search.rs` — add `SearchTrace` / `TraceCandidate` / `TraceFusionInput` / `TraceTiming` + `IndexBytes` types; extend `SearchOpts` with `trace: bool` +- `crates/kebab-store-sqlite/src/store.rs` — extend `CountSummary` with new fields, populate via new helpers +- `crates/kebab-app/src/schema.rs` — extend `Stats` mirror with new fields, wire collect_stats +- `crates/kebab-app/src/app.rs` — extend `SearchResponse` with `trace: Option`, thread trace through `App::search_with_opts` +- `crates/kebab-search/src/hybrid.rs` — add `HybridRetriever::search_with_trace` +- `crates/kebab-cli/src/main.rs` — add `--trace` flag to `Cmd::Search`, dispatch + non-JSON pretty-print +- `crates/kebab-cli/src/wire.rs` — extend `wire_search_response` to serialize `trace` field when present +- `crates/kebab-mcp/src/tools/search.rs` — add `trace: Option` to `SearchInput`, dispatch through +- `crates/kebab-tui/src/search.rs` — add `t` keystroke handler invoking trace + opening popup +- `crates/kebab-tui/src/app.rs` — store `trace_popup: Option` +- `crates/kebab-tui/src/cheatsheet.rs` — add `t = trace` line +- `crates/kebab-tui/src/lib.rs` — register `trace_popup` module +- `docs/wire-schema/v1/search_response.schema.json` — declare optional `trace` field +- `docs/wire-schema/v1/schema.schema.json` — declare new stats fields +- `README.md`, `docs/SMOKE.md`, `tasks/p9/p9-fb-37-trace-and-stats.md`, `tasks/INDEX.md`, `integrations/claude-code/kebab/SKILL.md` + +--- + +## Task 1: Trace + IndexBytes domain types in kebab-core + +**Files:** +- Modify: `crates/kebab-core/src/search.rs` + +- [ ] **Step 1: Write failing test for SearchTrace serde roundtrip** + +Append to `crates/kebab-core/src/search.rs` `mod tests`: +```rust +#[test] +fn search_trace_serde_roundtrip() { + let t = SearchTrace { + lexical: vec![TraceCandidate { + chunk_id: ChunkId("c1".into()), + doc_id: DocumentId("d1".into()), + doc_path: WorkspacePath::new("a.md".into()).unwrap(), + rank: 1, + score: 0.42, + }], + vector: vec![], + rrf_inputs: vec![TraceFusionInput { + chunk_id: ChunkId("c1".into()), + lexical_rank: Some(1), + vector_rank: None, + fusion_score: 0.0234, + }], + timing: TraceTiming { + lexical_ms: 12, + vector_ms: 0, + fusion_ms: 1, + total_ms: 14, + }, + }; + let v = serde_json::to_value(&t).unwrap(); + assert_eq!(v["timing"]["lexical_ms"], 12); + assert_eq!(v["lexical"][0]["score"], 0.42); + let back: SearchTrace = serde_json::from_value(v).unwrap(); + assert_eq!(back, t); +} + +#[test] +fn index_bytes_default_is_zero() { + let b = IndexBytes::default(); + assert_eq!(b.sqlite, 0); + assert_eq!(b.lancedb, 0); +} + +#[test] +fn search_opts_trace_default_false() { + let opts = SearchOpts::default(); + assert!(!opts.trace); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p kebab-core --lib +``` +Expected: compile errors — `SearchTrace`, `TraceCandidate`, `TraceFusionInput`, `TraceTiming`, `IndexBytes` not defined; `SearchOpts.trace` field missing. + +- [ ] **Step 3: Add types** + +Append to `crates/kebab-core/src/search.rs` (after existing `SearchOpts`): + +```rust +/// p9-fb-37: search retrieval pipeline trace. Populated only when +/// `SearchOpts.trace = true`; `None` on the wrapping `SearchResponse` +/// otherwise. `lexical` / `vector` are pre-fusion candidate lists +/// (each retriever's full output for the fanout query). `rrf_inputs` +/// is the union (chunk_id) used by RRF, with each side's rank +/// captured. `timing` is wall-clock per stage. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SearchTrace { + pub lexical: Vec, + pub vector: Vec, + pub rrf_inputs: Vec, + pub timing: TraceTiming, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TraceCandidate { + pub chunk_id: ChunkId, + pub doc_id: DocumentId, + pub doc_path: WorkspacePath, + pub rank: u32, + pub score: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TraceFusionInput { + pub chunk_id: ChunkId, + pub lexical_rank: Option, + pub vector_rank: Option, + pub fusion_score: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TraceTiming { + pub lexical_ms: u64, + pub vector_ms: u64, + pub fusion_ms: u64, + pub total_ms: u64, +} + +/// p9-fb-37: on-disk index size breakdown. Mirrored on the +/// wire `schema.v1.stats.index_bytes` block. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct IndexBytes { + pub sqlite: u64, + pub lancedb: u64, +} +``` + +Extend `SearchOpts` (replace the existing struct definition): + +```rust +/// p9-fb-34: caller-supplied output budget knobs for `App::search_with_opts`. +/// All `None` = no enforcement (existing behavior). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SearchOpts { + /// chars/4 approximation of wire JSON token cost. None = no cap. + pub max_tokens: Option, + /// Per-hit snippet character cap. None = use config default. + pub snippet_chars: Option, + /// Opaque base64 cursor from a previous response. None = first page. + pub cursor: Option, + /// p9-fb-37: when true, capture pipeline trace (cache bypassed, + /// lex / vec pre-fusion lists + timing populated on the response). + #[serde(default)] + pub trace: bool, +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test -p kebab-core --lib +``` +Expected: all 3 new tests pass; existing tests unaffected. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-core/src/search.rs +git commit -m "feat(core): SearchTrace + IndexBytes types + SearchOpts.trace (fb-37)" +``` + +--- + +## Task 2: SQLite breakdowns helper + +**Files:** +- Create: `crates/kebab-store-sqlite/src/stats_ext.rs` +- Modify: `crates/kebab-store-sqlite/src/lib.rs` (register module) + +- [ ] **Step 1: Write failing tests** + +Create `crates/kebab-store-sqlite/src/stats_ext.rs`: + +```rust +//! p9-fb-37: extended stats helpers — per-media / per-lang doc counts, +//! stale doc count, on-disk index byte sums. + +use std::collections::BTreeMap; +use std::path::Path; + +use kebab_core::{IndexBytes, MEDIA_KINDS}; +use rusqlite::Connection; + +/// Returns `(media_breakdown, lang_breakdown, stale_doc_count)`. +/// +/// `media_breakdown` always contains all 5 `MEDIA_KINDS` (zero-padded). +/// `lang_breakdown` only contains observed languages; NULL lang is +/// keyed as the literal string `"null"`. `stale_doc_count` is 0 when +/// `threshold_days == 0` (mirrors fb-32 staleness disable semantics). +pub fn breakdowns( + conn: &Connection, + threshold_days: u64, +) -> rusqlite::Result<(BTreeMap, BTreeMap, u64)> { + // media: dual JSON shape — text variant ("markdown") vs object + // variant ({"image":{"format":"png"}}). Same CASE WHEN as fb-36. + let mut media: BTreeMap = MEDIA_KINDS + .iter() + .map(|k| ((*k).to_string(), 0u64)) + .collect(); + let mut stmt = conn.prepare( + "SELECT \ + CASE \ + WHEN json_type(a.media_type) = 'text' \ + THEN json_extract(a.media_type, '$') \ + ELSE (SELECT key FROM json_each(a.media_type) LIMIT 1) \ + END AS kind, \ + COUNT(DISTINCT d.doc_id) \ + FROM documents d JOIN assets a ON a.asset_id = d.asset_id \ + GROUP BY kind", + )?; + let rows = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)) + })?; + for row in rows { + let (kind, n) = row?; + media.insert(kind, n); + } + + let mut lang: BTreeMap = BTreeMap::new(); + let mut stmt = conn.prepare( + "SELECT COALESCE(lang, 'null') AS l, COUNT(*) \ + FROM documents GROUP BY l", + )?; + let rows = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)) + })?; + for row in rows { + let (l, n) = row?; + lang.insert(l, n); + } + + let stale: u64 = if threshold_days == 0 { + 0 + } else { + let secs = (threshold_days as i64) * 86_400; + let cutoff = time::OffsetDateTime::now_utc() + - time::Duration::seconds(secs); + let cutoff_str = cutoff + .format(&time::format_description::well_known::Rfc3339) + .expect("RFC3339 format"); + conn.query_row( + "SELECT COUNT(*) FROM documents WHERE updated_at < ?", + [cutoff_str], + |r| r.get(0), + )? + }; + + Ok((media, lang, stale)) +} + +/// Sum on-disk bytes of the SQLite database (main + WAL + SHM) and +/// the LanceDB directory tree. Missing files / dir = 0. +pub fn index_bytes(data_dir: &Path) -> std::io::Result { + fn file_size_or_zero(p: &Path) -> u64 { + std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) + } + fn dir_walk_sum(p: &Path) -> std::io::Result { + if !p.exists() { + return Ok(0); + } + let mut total = 0u64; + for entry in std::fs::read_dir(p)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + total += dir_walk_sum(&entry.path())?; + } else if ty.is_file() { + total += entry.metadata()?.len(); + } + } + Ok(total) + } + + let sqlite_main = data_dir.join("kebab.sqlite"); + let sqlite_wal = data_dir.join("kebab.sqlite-wal"); + let sqlite_shm = data_dir.join("kebab.sqlite-shm"); + let sqlite = file_size_or_zero(&sqlite_main) + + file_size_or_zero(&sqlite_wal) + + file_size_or_zero(&sqlite_shm); + let lancedb = dir_walk_sum(&data_dir.join("lancedb"))?; + Ok(IndexBytes { sqlite, lancedb }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open_fresh() -> (tempfile::TempDir, crate::SqliteStore) { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = kebab_config::Config::defaults(); + cfg.storage.data_dir = dir.path().to_string_lossy().into_owned(); + let store = crate::SqliteStore::open(&cfg).unwrap(); + store.run_migrations().unwrap(); + (dir, store) + } + + #[test] + fn breakdowns_empty_corpus() { + let (_dir, store) = open_fresh(); + let conn = store.read_conn(); + let (media, lang, stale) = breakdowns(&conn, 0).unwrap(); + // 5 keys all zero, lang map empty, stale 0. + assert_eq!(media.len(), 5); + for k in MEDIA_KINDS { + assert_eq!(media.get(*k), Some(&0u64)); + } + assert!(lang.is_empty()); + assert_eq!(stale, 0); + } + + #[test] + fn index_bytes_includes_sqlite_main() { + let (dir, _store) = open_fresh(); + let b = index_bytes(dir.path()).unwrap(); + assert!(b.sqlite > 0, "main sqlite file should exist after migrations"); + assert_eq!(b.lancedb, 0); + } + + #[test] + fn index_bytes_lancedb_dir_walk() { + let dir = tempfile::tempdir().unwrap(); + let lance = dir.path().join("lancedb"); + std::fs::create_dir_all(lance.join("vectors.lance")).unwrap(); + std::fs::write( + lance.join("vectors.lance").join("data.bin"), + vec![0u8; 1024], + ) + .unwrap(); + let b = index_bytes(dir.path()).unwrap(); + assert_eq!(b.lancedb, 1024); + } +} +``` + +Modify `crates/kebab-store-sqlite/src/lib.rs`. Find the existing `pub mod` declarations and add: + +```rust +pub mod stats_ext; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p kebab-store-sqlite stats_ext +``` +Expected: build error initially (module exists but test imports `MEDIA_KINDS` from kebab-core); resolve any compile issue, then run again. Tests should pass with the implementation provided in Step 1 — this is a test-with-implementation step (verifying via cargo). + +Actually since the implementation is already in stats_ext.rs in Step 1, run: +```bash +cargo test -p kebab-store-sqlite stats_ext +``` +Expected: 3 new tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add crates/kebab-store-sqlite/src/stats_ext.rs crates/kebab-store-sqlite/src/lib.rs +git commit -m "feat(store): breakdowns + index_bytes helpers (fb-37)" +``` + +--- + +## Task 3: Extend CountSummary + wire to schema.v1.stats + +**Files:** +- Modify: `crates/kebab-store-sqlite/src/store.rs` +- Modify: `crates/kebab-app/src/schema.rs` + +- [ ] **Step 1: Write failing test in kebab-app** + +Append to `crates/kebab-app/src/schema.rs` `mod tests` section (or create one if absent — check around line 200+): + +```rust +#[cfg(test)] +mod tests_stats_ext { + use super::*; + + #[test] + fn stats_includes_breakdowns_and_bytes_on_fresh_corpus() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = kebab_config::Config::defaults(); + cfg.storage.data_dir = dir.path().to_string_lossy().into_owned(); + // Bring up migrations so the sqlite file is created. + let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap(); + store.run_migrations().unwrap(); + drop(store); + + let s = schema_with_config(&cfg).unwrap(); + // 5 keys padded. + assert_eq!(s.stats.media_breakdown.len(), 5); + assert_eq!(s.stats.media_breakdown.get("markdown"), Some(&0)); + assert_eq!(s.stats.media_breakdown.get("pdf"), Some(&0)); + // lang map empty on empty corpus. + assert!(s.stats.lang_breakdown.is_empty()); + // sqlite bytes positive after migrations, lancedb 0. + assert!(s.stats.index_bytes.sqlite > 0); + assert_eq!(s.stats.index_bytes.lancedb, 0); + assert_eq!(s.stats.stale_doc_count, 0); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p kebab-app stats_includes_breakdowns_and_bytes_on_fresh_corpus +``` +Expected: compile error — `Stats` lacks `media_breakdown`, `lang_breakdown`, `index_bytes`, `stale_doc_count`. + +- [ ] **Step 3: Extend `CountSummary`** + +Modify `crates/kebab-store-sqlite/src/store.rs`. Find `pub struct CountSummary` (~line 595-606) and replace with: + +```rust +#[derive(Debug, Clone)] +pub struct CountSummary { + pub doc_count: u64, + pub chunk_count: u64, + pub asset_count: u64, + /// ISO-8601 timestamp of the most-recently updated document row, or + /// `None` when the store is empty. + pub last_ingest_at: Option, + /// p9-fb-37: per-media-kind doc count (5 keys, zero-padded). + pub media_breakdown: std::collections::BTreeMap, + /// p9-fb-37: per-language doc count, NULL keyed as `"null"`. + pub lang_breakdown: std::collections::BTreeMap, + /// p9-fb-37: docs whose `updated_at < now - threshold_days`. 0 when threshold=0. + pub stale_doc_count: u64, +} +``` + +Modify `count_summary` body (around line 615-650) to populate new fields. Replace the body of `pub fn count_summary(&self) -> anyhow::Result`: + +```rust +pub fn count_summary(&self) -> anyhow::Result { + use anyhow::Context; + use rusqlite::OptionalExtension; + + let conn = self.read_conn(); + + let doc_count: u64 = conn + .query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0)) + .context("count documents")?; + let chunk_count: u64 = conn + .query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0)) + .context("count chunks")?; + let asset_count: u64 = conn + .query_row("SELECT COUNT(*) FROM assets", [], |r| r.get(0)) + .context("count assets")?; + let last_ingest_at: Option = conn + .query_row("SELECT MAX(updated_at) FROM documents", [], |r| r.get(0)) + .optional() + .context("max updated_at")? + .flatten(); + + // p9-fb-37: pull threshold from config-defaults via a sentinel — + // CountSummary callers that want correct stale_doc_count must + // pass through count_summary_with_threshold. Default path uses 0 + // (matches fb-32 disable semantics) for backwards compat. + let (media_breakdown, lang_breakdown, stale_doc_count) = + crate::stats_ext::breakdowns(&conn, 0).context("breakdowns")?; + + Ok(CountSummary { + doc_count, + chunk_count, + asset_count, + last_ingest_at, + media_breakdown, + lang_breakdown, + stale_doc_count, + }) +} + +/// p9-fb-37: variant that honors `config.search.stale_threshold_days`. +/// Callers who need a meaningful `stale_doc_count` (e.g. `kebab schema`) +/// pass the configured threshold; the older `count_summary` returns 0. +pub fn count_summary_with_threshold( + &self, + threshold_days: u64, +) -> anyhow::Result { + use anyhow::Context; + let mut s = self.count_summary()?; + let conn = self.read_conn(); + let (m, l, stale) = crate::stats_ext::breakdowns(&conn, threshold_days) + .context("breakdowns_with_threshold")?; + s.media_breakdown = m; + s.lang_breakdown = l; + s.stale_doc_count = stale; + Ok(s) +} +``` + +Update existing `count_summary_zero_on_fresh_store` test (~line 678) to assert new fields: + +```rust +#[test] +fn count_summary_zero_on_fresh_store() { + let (_dir, store) = open_fresh_store(); + 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()); + assert_eq!(s.media_breakdown.len(), 5); + assert!(s.lang_breakdown.is_empty()); + assert_eq!(s.stale_doc_count, 0); +} +``` + +- [ ] **Step 4: Extend `Stats` mirror in kebab-app::schema** + +Modify `crates/kebab-app/src/schema.rs`. Replace `pub struct Stats`: + +```rust +#[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, + /// p9-fb-37: per-media-kind doc count (5 keys, zero-padded). + #[serde(default)] + pub media_breakdown: std::collections::BTreeMap, + /// p9-fb-37: per-language doc count, NULL keyed as `"null"`. + #[serde(default)] + pub lang_breakdown: std::collections::BTreeMap, + /// p9-fb-37: on-disk byte sums. + #[serde(default)] + pub index_bytes: kebab_core::IndexBytes, + /// p9-fb-37: docs whose `updated_at` exceeds the staleness threshold. + #[serde(default)] + pub stale_doc_count: u64, +} +``` + +Replace `collect_stats` body: + +```rust +fn collect_stats( + cfg: &Config, + store: &kebab_store_sqlite::SqliteStore, +) -> anyhow::Result { + let counts = store + .count_summary_with_threshold(cfg.search.stale_threshold_days as u64)?; + let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, ""); + let index_bytes = kebab_store_sqlite::stats_ext::index_bytes(&data_dir) + .map_err(|e| anyhow::anyhow!("index_bytes: {e}"))?; + Ok(Stats { + doc_count: counts.doc_count, + chunk_count: counts.chunk_count, + asset_count: counts.asset_count, + last_ingest_at: counts.last_ingest_at, + media_breakdown: counts.media_breakdown, + lang_breakdown: counts.lang_breakdown, + index_bytes, + stale_doc_count: counts.stale_doc_count, + }) +} +``` + +Update the call site `let stats = collect_stats(&store)?;` (~line 88) to: + +```rust +let stats = collect_stats(cfg, &store)?; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cargo test -p kebab-store-sqlite count_summary +cargo test -p kebab-app stats_includes_breakdowns_and_bytes_on_fresh_corpus +``` +Expected: both pass. + +- [ ] **Step 6: Verify config field type** + +`cfg.search.stale_threshold_days` must exist as integer. Check `crates/kebab-config/src/lib.rs` for `Search.stale_threshold_days`. If type mismatch (e.g. it's `u32`), adjust `as u64` cast accordingly. + +```bash +grep -n "stale_threshold_days" crates/kebab-config/src/lib.rs +``` +Expected: line with the field type. If it's already `u64` drop the cast; if `u32` keep `as u64`. + +- [ ] **Step 7: Run full clippy + workspace tests** + +```bash +cargo clippy -p kebab-core -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings +cargo test -p kebab-core -p kebab-store-sqlite -p kebab-app +``` +Expected: clippy clean, all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/kebab-store-sqlite/src/store.rs crates/kebab-app/src/schema.rs +git commit -m "feat(stats): media/lang/bytes/stale fields on schema.v1.stats (fb-37)" +``` + +--- + +## Task 4: HybridRetriever search_with_trace + +**Files:** +- Create: `crates/kebab-search/src/trace.rs` +- Modify: `crates/kebab-search/src/hybrid.rs` +- Modify: `crates/kebab-search/src/lib.rs` + +- [ ] **Step 1: Write failing test in hybrid.rs** + +Append to `crates/kebab-search/src/hybrid.rs` `mod tests`: + +```rust +#[test] +fn search_with_trace_returns_lex_and_vec_lists() { + use kebab_core::{ChunkId, DocumentId, IndexVersion, ChunkerVersion, + RetrievalDetail, SearchHit, SearchMode, SearchQuery, + WorkspacePath, Citation}; + use std::sync::Arc; + + fn mk_hit(rank: u32, chunk: &str, score: f32, mode: SearchMode) -> SearchHit { + SearchHit { + rank, + chunk_id: ChunkId(chunk.into()), + doc_id: DocumentId(format!("d-{chunk}")), + doc_path: WorkspacePath::new(format!("{chunk}.md")).unwrap(), + heading_path: vec![], + section_label: None, + snippet: chunk.into(), + citation: Citation::Line { + path: WorkspacePath::new(format!("{chunk}.md")).unwrap(), + start: 1, + end: 1, + section: None, + }, + retrieval: RetrievalDetail { + method: mode, + fusion_score: score, + lexical_score: if mode == SearchMode::Lexical { Some(score) } else { None }, + vector_score: if mode == SearchMode::Vector { Some(score) } else { None }, + lexical_rank: if mode == SearchMode::Lexical { Some(rank) } else { None }, + vector_rank: if mode == SearchMode::Vector { Some(rank) } else { None }, + }, + index_version: IndexVersion("v1".into()), + embedding_model: None, + chunker_version: ChunkerVersion("c1".into()), + indexed_at: time::OffsetDateTime::UNIX_EPOCH, + stale: false, + } + } + + // Stub retrievers from existing test patterns in this file (see + // `MockRetriever` near line 363 if present, otherwise inline). + struct Stub { hits: Vec, mode: SearchMode } + impl Retriever for Stub { + fn search(&self, _q: &SearchQuery) -> anyhow::Result> { + Ok(self.hits.clone()) + } + fn index_version(&self) -> IndexVersion { IndexVersion("v1".into()) } + } + + let lex = Arc::new(Stub { + hits: vec![ + mk_hit(1, "c1", 0.9, SearchMode::Lexical), + mk_hit(2, "c2", 0.5, SearchMode::Lexical), + ], + mode: SearchMode::Lexical, + }); + let vec_r = Arc::new(Stub { + hits: vec![ + mk_hit(1, "c2", 0.8, SearchMode::Vector), + mk_hit(2, "c3", 0.6, SearchMode::Vector), + ], + mode: SearchMode::Vector, + }); + let hybrid = HybridRetriever::with_policy( + lex.clone(), + vec_r.clone(), + FusionPolicy::Rrf { k: 60 }, + 2, + ); + let q = SearchQuery { + text: "x".into(), + mode: SearchMode::Hybrid, + k: 2, + filters: Default::default(), + }; + let (hits, trace) = hybrid.search_with_trace(&q).unwrap(); + assert!(!hits.is_empty()); + assert_eq!(trace.lexical.len(), 2); + assert_eq!(trace.vector.len(), 2); + // Union: c1, c2, c3 → 3 entries. + assert_eq!(trace.rrf_inputs.len(), 3); + // Sanity: timing populated (any field >= 0 trivially; just check + // the type was set, not a Default::default()). + let _ = trace.timing.lexical_ms; +} + +#[test] +fn search_with_trace_lexical_mode_empty_vector() { + use kebab_core::{ChunkId, DocumentId, IndexVersion, ChunkerVersion, + RetrievalDetail, SearchHit, SearchMode, SearchQuery, + WorkspacePath, Citation}; + use std::sync::Arc; + struct EmptyR(SearchMode); + impl Retriever for EmptyR { + fn search(&self, _q: &SearchQuery) -> anyhow::Result> { + Ok(vec![]) + } + fn index_version(&self) -> IndexVersion { IndexVersion("v1".into()) } + } + let lex = Arc::new(EmptyR(SearchMode::Lexical)); + let vec_r = Arc::new(EmptyR(SearchMode::Vector)); + let hybrid = HybridRetriever::with_policy(lex, vec_r, FusionPolicy::Rrf { k: 60 }, 2); + let q = SearchQuery { + text: "x".into(), + mode: SearchMode::Lexical, + k: 2, + filters: Default::default(), + }; + let (_hits, trace) = hybrid.search_with_trace(&q).unwrap(); + assert!(trace.vector.is_empty()); + assert_eq!(trace.timing.vector_ms, 0); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p kebab-search hybrid::tests::search_with_trace +``` +Expected: compile error — `search_with_trace` undefined. + +- [ ] **Step 3: Add `trace.rs` helper module** + +Create `crates/kebab-search/src/trace.rs`: + +```rust +//! p9-fb-37: trace capture helpers for `HybridRetriever::search_with_trace`. + +use std::collections::BTreeMap; + +use kebab_core::{ + SearchHit, SearchTrace, TraceCandidate, TraceFusionInput, TraceTiming, +}; + +/// Build a `TraceCandidate` from a `SearchHit`. The score field reflects +/// each side's score (lexical / vector / fusion) — caller selects which +/// retriever's hit list this is. +pub fn candidates_from_hits(hits: &[SearchHit], score_kind: ScoreKind) -> Vec { + hits.iter() + .map(|h| TraceCandidate { + chunk_id: h.chunk_id.clone(), + doc_id: h.doc_id.clone(), + doc_path: h.doc_path.clone(), + rank: h.rank, + score: match score_kind { + ScoreKind::Lexical => h.retrieval.lexical_score.unwrap_or(0.0), + ScoreKind::Vector => h.retrieval.vector_score.unwrap_or(0.0), + }, + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +pub enum ScoreKind { + Lexical, + Vector, +} + +/// Build the union of (chunk_id) across lex and vec hit lists, with +/// each side's rank captured. `fusion_score` is filled by the caller +/// (RRF computes it during fusion, this helper just pre-builds the +/// rank table — caller overwrites fusion_score in a second pass). +pub fn build_fusion_input_skeleton( + lex: &[SearchHit], + vec: &[SearchHit], +) -> Vec { + let mut by_chunk: BTreeMap = BTreeMap::new(); + for h in lex { + by_chunk + .entry(h.chunk_id.0.clone()) + .or_insert(TraceFusionInput { + chunk_id: h.chunk_id.clone(), + lexical_rank: None, + vector_rank: None, + fusion_score: 0.0, + }) + .lexical_rank = Some(h.rank); + } + for h in vec { + by_chunk + .entry(h.chunk_id.0.clone()) + .or_insert(TraceFusionInput { + chunk_id: h.chunk_id.clone(), + lexical_rank: None, + vector_rank: None, + fusion_score: 0.0, + }) + .vector_rank = Some(h.rank); + } + by_chunk.into_values().collect() +} + +/// Container the hybrid retriever fills during a traced run. +#[derive(Default)] +pub struct TraceBuilder { + pub lexical: Vec, + pub vector: Vec, + pub rrf_inputs: Vec, + pub timing: TraceTiming, +} + +impl TraceBuilder { + pub fn into_trace(self) -> SearchTrace { + SearchTrace { + lexical: self.lexical, + vector: self.vector, + rrf_inputs: self.rrf_inputs, + timing: self.timing, + } + } +} +``` + +Modify `crates/kebab-search/src/lib.rs`. Add module declaration: + +```rust +mod trace; +``` + +- [ ] **Step 4: Add `search_with_trace` on HybridRetriever** + +Modify `crates/kebab-search/src/hybrid.rs`. Add at the top (under existing `use` lines): + +```rust +use crate::trace::{build_fusion_input_skeleton, candidates_from_hits, ScoreKind, TraceBuilder}; +use kebab_core::SearchTrace; +use std::time::Instant; +``` + +Add a method to `impl HybridRetriever` (place after `fn fuse`): + +```rust +/// p9-fb-37: parallel to `Retriever::search` but additionally returns +/// a trace of pre-fusion lex/vec lists, RRF inputs (union with each +/// side's rank), and per-stage timing. Same fan-out logic as `fuse`, +/// just instrumented. +pub fn search_with_trace( + &self, + query: &SearchQuery, +) -> anyhow::Result<(Vec, SearchTrace)> { + let start_total = Instant::now(); + let target_k = if query.k == 0 { self.default_k } else { query.k }; + let fanout_k = target_k.saturating_mul(HYBRID_FANOUT_MULTIPLIER); + let fanout_query = SearchQuery { + k: fanout_k, + ..query.clone() + }; + + let mut tb = TraceBuilder::default(); + + let (lex_hits, vec_hits): (Vec, Vec) = match query.mode { + SearchMode::Lexical => { + let t0 = Instant::now(); + let lh = self.lexical.search(&fanout_query)?; + tb.timing.lexical_ms = t0.elapsed().as_millis() as u64; + (lh, Vec::new()) + } + SearchMode::Vector => { + let t0 = Instant::now(); + let vh = self.vector.search(&fanout_query)?; + tb.timing.vector_ms = t0.elapsed().as_millis() as u64; + (Vec::new(), vh) + } + SearchMode::Hybrid => { + let t0 = Instant::now(); + let lh = self.lexical.search(&fanout_query)?; + tb.timing.lexical_ms = t0.elapsed().as_millis() as u64; + let t1 = Instant::now(); + let vh = self.vector.search(&fanout_query)?; + tb.timing.vector_ms = t1.elapsed().as_millis() as u64; + (lh, vh) + } + }; + + tb.lexical = candidates_from_hits(&lex_hits, ScoreKind::Lexical); + tb.vector = candidates_from_hits(&vec_hits, ScoreKind::Vector); + tb.rrf_inputs = build_fusion_input_skeleton(&lex_hits, &vec_hits); + + let t_fusion = Instant::now(); + let final_hits = match query.mode { + SearchMode::Lexical => { + let mut h = lex_hits.clone(); + h.truncate(target_k); + h + } + SearchMode::Vector => { + let mut h = vec_hits.clone(); + h.truncate(target_k); + h + } + SearchMode::Hybrid => self.fuse_with_inputs(&lex_hits, &vec_hits, target_k)?, + }; + tb.timing.fusion_ms = t_fusion.elapsed().as_millis() as u64; + + // Backfill fusion_score onto the rrf_inputs union for each chunk + // present in the final fused list. + let score_by_chunk: std::collections::HashMap = final_hits + .iter() + .map(|h| (h.chunk_id.0.clone(), h.retrieval.fusion_score)) + .collect(); + for entry in &mut tb.rrf_inputs { + if let Some(s) = score_by_chunk.get(&entry.chunk_id.0) { + entry.fusion_score = *s; + } + } + + tb.timing.total_ms = start_total.elapsed().as_millis() as u64; + Ok((final_hits, tb.into_trace())) +} +``` + +`fuse_with_inputs` is needed — extract from existing `fuse` so both `Retriever::search` (hybrid mode) and `search_with_trace` reuse the same RRF body without re-querying retrievers. + +Refactoring recipe: +1. Read existing `fn fuse` (at line ~145). Note the body issues two `.search()` calls then builds `lex_index` / `vec_index` via `.into_iter()`. +2. Split into two functions. `fn fuse` keeps the two `.search()` calls, then delegates the rest. `fn fuse_with_inputs` takes the already-resolved hit slices. +3. Inside `fuse_with_inputs`: replace `let lex_index: HashMap<...> = lex_hits.into_iter().map(...).collect();` with `let lex_index: HashMap<...> = lex_hits.iter().cloned().map(...).collect();` (mirror for vec_index). All other RRF logic stays identical. + +```rust +fn fuse(&self, query: &SearchQuery) -> Result> { + let target_k = if query.k == 0 { self.default_k } else { query.k }; + let fanout_k = target_k.saturating_mul(HYBRID_FANOUT_MULTIPLIER); + let fanout_query = SearchQuery { + k: fanout_k, + ..query.clone() + }; + let lex_hits = self.lexical.search(&fanout_query)?; + let vec_hits = self.vector.search(&fanout_query)?; + self.fuse_with_inputs(&lex_hits, &vec_hits, target_k) +} + +fn fuse_with_inputs( + &self, + lex_hits: &[SearchHit], + vec_hits: &[SearchHit], + target_k: usize, +) -> Result> { + tracing::debug!( + lex = lex_hits.len(), + vec = vec_hits.len(), + target_k, + "kb-search hybrid: pre-fusion candidate counts" + ); + // PASTE the rest of the original `fn fuse` body here. Two changes: + // - replace `lex_hits.into_iter()` with `lex_hits.iter().cloned()` + // - replace `vec_hits.into_iter()` with `vec_hits.iter().cloned()` + // Everything else (RRF score formula, sort, truncate to target_k, + // tie-breaking, `Ok(...)` return) is verbatim preserved. +} +``` + +Verify with `cargo test -p kebab-search` — existing hybrid tests must still pass (they exercise the `Retriever::search` → `fuse` path). + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p kebab-search +``` +Expected: existing hybrid tests still pass + 2 new search_with_trace tests pass. + +- [ ] **Step 6: Clippy gate** + +```bash +cargo clippy -p kebab-search --all-targets -- -D warnings +``` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/kebab-search/src/trace.rs crates/kebab-search/src/hybrid.rs crates/kebab-search/src/lib.rs +git commit -m "feat(search): HybridRetriever::search_with_trace (fb-37)" +``` + +--- + +## Task 5: SearchResponse trace field + App::search_with_opts threading + +**Files:** +- Modify: `crates/kebab-app/src/app.rs` + +- [ ] **Step 1: Write failing test** + +Append to `crates/kebab-app/src/app.rs` tests module (find existing `#[cfg(test)] mod tests` near bottom; if absent, add one at file end): + +```rust +#[cfg(test)] +mod tests_trace { + use super::*; + use kebab_core::{SearchOpts, SearchQuery, SearchMode}; + + fn open_app_with_temp_dir() -> (tempfile::TempDir, App) { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = kebab_config::Config::defaults(); + cfg.storage.data_dir = dir.path().to_string_lossy().into_owned(); + // Ensure DB exists. + let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap(); + store.run_migrations().unwrap(); + drop(store); + let app = App::open_with_config(cfg).unwrap(); + (dir, app) + } + + #[test] + fn search_response_trace_none_when_opts_trace_false() { + let (_dir, app) = open_app_with_temp_dir(); + let q = SearchQuery { + text: "x".into(), + mode: SearchMode::Lexical, + k: 1, + filters: Default::default(), + }; + let resp = app.search_with_opts(q, SearchOpts::default()).unwrap(); + assert!(resp.trace.is_none()); + } + + #[test] + fn search_response_trace_some_when_opts_trace_true() { + let (_dir, app) = open_app_with_temp_dir(); + let q = SearchQuery { + text: "x".into(), + mode: SearchMode::Lexical, + k: 1, + filters: Default::default(), + }; + let opts = SearchOpts { trace: true, ..Default::default() }; + let resp = app.search_with_opts(q, opts).unwrap(); + assert!(resp.trace.is_some(), "trace populated when opts.trace=true"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p kebab-app tests_trace +``` +Expected: compile errors — `SearchResponse.trace` field absent. + +- [ ] **Step 3: Extend `SearchResponse`** + +In `crates/kebab-app/src/app.rs`, replace `pub struct SearchResponse` (~line 69): + +```rust +#[derive(Clone, Debug)] +pub struct SearchResponse { + pub hits: Vec, + pub next_cursor: Option, + pub truncated: bool, + /// p9-fb-37: present when caller passed `SearchOpts.trace = true`. + /// Consumers that ignore trace should leave this `None`. + pub trace: Option, +} +``` + +- [ ] **Step 4: Thread through `App::search_with_opts`** + +In `crates/kebab-app/src/app.rs`, modify `pub fn search_with_opts` (~line 306) to honor `opts.trace`. Find the current `let mut all_hits = self.search(fetch_query)?;` line and replace surrounding logic: + +```rust +let trace = if opts.trace { + // Build a trace-capable retriever directly. Re-use construction + // from the cached search path but bypass cache (debug intent). + let retriever = self.build_retriever()?; + let traced = retriever + .as_any() + .downcast_ref::() + .map(|h| h.search_with_trace(&fetch_query)); + if let Some(Ok((hits, t))) = traced { + let mut all_hits = hits; + let drop_n = offset.min(all_hits.len()); + all_hits.drain(..drop_n); + let final_hits: Vec = all_hits.into_iter().take(k_effective).collect(); + return Ok(self.build_response(final_hits, k_effective, &opts, snippet_chars, Some(t))); + } + None +} else { + None +}; + +let mut all_hits = self.search(fetch_query)?; +// ... existing code ... +``` + +Engineer note: this is a sketch — review actual `App::search_with_opts` body before editing; the `build_retriever` / `as_any` / `build_response` helpers may not exist verbatim. The minimal change required is: +1. When `opts.trace = true`, call `search_with_trace` on the hybrid retriever (constructed the same way `App::search_uncached` does). +2. Bypass the search cache entirely. +3. Plug the resulting `SearchTrace` into `SearchResponse.trace`. + +Use the existing `App::search_uncached` (line ~243) as the model — duplicate that path with `search_with_trace` and wrap the result. Look for: `let retriever = ... HybridRetriever::new(&self.config, lex, vec);`. Call `retriever.search_with_trace(&query)` instead of `retriever.search(&query)` when tracing. + +If the retriever is constructed only as `Arc` (and `search_with_trace` is not on the trait), add a concrete-typed local construction in the `if opts.trace` branch. Example pattern: + +```rust +// inside fn search_with_opts: +if opts.trace { + use kebab_search::HybridRetriever; + let lex = self.build_lexical_retriever()?; + let vec = self.build_vector_retriever()?; + let retriever = HybridRetriever::new(&self.config, lex, vec); + let (hits, trace) = retriever.search_with_trace(&fetch_query)?; + // skip cache, run budget loop on hits, attach trace to response + return Ok(self.finalize_response(hits, k_effective, offset, &opts, snippet_chars, Some(trace))); +} +``` + +The exact helpers (`build_lexical_retriever`, `finalize_response`) are method names you'll either find or extract during implementation. Goal: trace path bypasses cache and returns `Some(trace)`; non-trace path unchanged returns `None`. + +Also update every other `SearchResponse { ... }` constructor in `app.rs` and `lib.rs` to include `trace: None`. Search for `SearchResponse {` to find all sites. + +```bash +grep -n "SearchResponse {" crates/kebab-app/src/app.rs crates/kebab-app/src/lib.rs +``` + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p kebab-app tests_trace +cargo test -p kebab-app +``` +Expected: 2 new trace tests pass; existing app tests unaffected. + +- [ ] **Step 6: Workspace clippy** + +```bash +cargo clippy -p kebab-app --all-targets -- -D warnings +``` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/kebab-app/src/app.rs +git commit -m "feat(app): SearchResponse.trace + opts.trace threading (fb-37)" +``` + +--- + +## Task 6: CLI --trace flag + JSON wire + non-JSON pretty print + +**Files:** +- Modify: `crates/kebab-cli/src/main.rs` +- Modify: `crates/kebab-cli/src/wire.rs` + +- [ ] **Step 1: Write failing test for wire serialization** + +Append to `crates/kebab-cli/src/wire.rs` `mod tests`: + +```rust +#[test] +fn search_response_with_trace_serializes_trace_field() { + use kebab_core::{SearchTrace, TraceCandidate, TraceFusionInput, + TraceTiming, ChunkId, DocumentId, WorkspacePath}; + let r = kebab_app::SearchResponse { + hits: vec![], + next_cursor: None, + truncated: false, + trace: Some(SearchTrace { + lexical: vec![TraceCandidate { + chunk_id: ChunkId("c1".into()), + doc_id: DocumentId("d1".into()), + doc_path: WorkspacePath::new("a.md".into()).unwrap(), + rank: 1, + score: 0.42, + }], + vector: vec![], + rrf_inputs: vec![TraceFusionInput { + chunk_id: ChunkId("c1".into()), + lexical_rank: Some(1), + vector_rank: None, + fusion_score: 0.0, + }], + timing: TraceTiming { lexical_ms: 5, vector_ms: 0, fusion_ms: 1, total_ms: 7 }, + }), + }; + let v = wire_search_response(&r); + assert_eq!(v["schema_version"], "search_response.v1"); + assert!(v["trace"].is_object()); + assert_eq!(v["trace"]["timing"]["lexical_ms"], 5); + assert_eq!(v["trace"]["lexical"][0]["chunk_id"], "c1"); +} + +#[test] +fn search_response_without_trace_omits_field() { + let r = kebab_app::SearchResponse { + hits: vec![], + next_cursor: None, + truncated: false, + trace: None, + }; + let v = wire_search_response(&r); + assert!(v.get("trace").is_none(), "trace field absent when None"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p kebab-cli wire::tests::search_response_with_trace_serializes_trace_field +``` +Expected: compile error — `SearchResponse.trace` not threaded into wire helper output. + +- [ ] **Step 3: Update `wire_search_response`** + +Modify `crates/kebab-cli/src/wire.rs` `wire_search_response`: + +```rust +pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value { + let mut v = serde_json::json!({ + "hits": r.hits.iter().map(wire_search_hit).collect::>(), + "next_cursor": r.next_cursor, + "truncated": r.truncated, + }); + if let Some(trace) = &r.trace { + let trace_v = serde_json::to_value(trace).expect("SearchTrace serializes"); + if let Value::Object(ref mut map) = v { + map.insert("trace".to_string(), trace_v); + } + } + tag_object(v, "search_response.v1") +} +``` + +- [ ] **Step 4: Add `--trace` clap flag** + +Modify `crates/kebab-cli/src/main.rs`. Find `Cmd::Search { ... }` definition (~line 95-150). Add at the end of its field list (after `doc_id`): + +```rust + /// p9-fb-37: emit pre-fusion lexical / vector / RRF candidate + /// lists + per-stage timing in the response. Bypasses cache + /// (debug intent — fresh run guaranteed). + #[arg(long)] + trace: bool, +``` + +Find the `Cmd::Search` dispatch arm (~line 656). Add `trace,` to the destructure pattern (after `doc_id,`). Find where `SearchOpts` is constructed (~look for `SearchOpts {` inside the search arm, ~line 745) and add `trace: *trace,`. Example: + +```rust +let opts = kebab_core::SearchOpts { + max_tokens: *max_tokens, + snippet_chars: *snippet_chars, + cursor: cursor.clone(), + trace: *trace, +}; +``` + +- [ ] **Step 5: Add non-JSON pretty-print** + +Find the search dispatch's non-JSON branch (the `else` of `if cli.json`, ~line 750-780). After hits are printed, add: + +```rust +if *trace { + if let Some(t) = &resp.trace { + eprintln!(); + eprintln!("Trace:"); + eprintln!(" lexical ({} hits, {}ms):", t.lexical.len(), t.timing.lexical_ms); + for c in t.lexical.iter().take(3) { + eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0); + } + eprintln!(" vector ({} hits, {}ms):", t.vector.len(), t.timing.vector_ms); + for c in t.vector.iter().take(3) { + eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0); + } + eprintln!(" fusion ({} inputs, {}ms)", t.rrf_inputs.len(), t.timing.fusion_ms); + eprintln!(" total: {}ms", t.timing.total_ms); + } +} +``` + +- [ ] **Step 6: Run tests** + +```bash +cargo test -p kebab-cli wire::tests +cargo test -p kebab-cli +``` +Expected: 2 new wire tests pass; existing cli tests unaffected. + +- [ ] **Step 7: Clippy** + +```bash +cargo clippy -p kebab-cli --all-targets -- -D warnings +``` +Expected: clean. + +- [ ] **Step 8: Commit** + +```bash +git add crates/kebab-cli/src/main.rs crates/kebab-cli/src/wire.rs +git commit -m "feat(cli): kebab search --trace flag + wire trace + pretty print (fb-37)" +``` + +--- + +## Task 7: CLI integration tests for --trace and stats breakdowns + +**Files:** +- Create: `crates/kebab-cli/tests/wire_search_trace.rs` +- Create: `crates/kebab-cli/tests/wire_schema_breakdowns.rs` + +- [ ] **Step 1: Write failing integration tests for --trace** + +Create `crates/kebab-cli/tests/wire_search_trace.rs`. Use the same fixture pattern as existing `crates/kebab-cli/tests/wire_search_filters.rs` (read it first to mirror temp-dir + ingest setup): + +```rust +//! p9-fb-37: integration tests for `kebab search --trace --json`. + +use std::process::Command; + +mod common; +use common::{cargo_bin, ingest_fixture, temp_kebab_root}; + +#[test] +fn search_trace_json_includes_trace_block() { + let (_root, cfg_path) = temp_kebab_root(); + ingest_fixture(&cfg_path, "doc1.md", "# Title\n\nrust async hello\n"); + + let out = Command::new(cargo_bin()) + .args([ + "--config", cfg_path.to_str().unwrap(), + "search", "rust", "--trace", "--json", + ]) + .output() + .expect("run"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["schema_version"], "search_response.v1"); + assert!(v["trace"].is_object(), "trace block present"); + assert!(v["trace"]["timing"].is_object()); + assert!(v["trace"]["timing"]["total_ms"].is_number()); + assert!(v["trace"]["lexical"].is_array()); + assert!(v["trace"]["vector"].is_array()); + assert!(v["trace"]["rrf_inputs"].is_array()); +} + +#[test] +fn search_without_trace_omits_trace_field() { + let (_root, cfg_path) = temp_kebab_root(); + ingest_fixture(&cfg_path, "doc1.md", "# Title\n\nrust async hello\n"); + + let out = Command::new(cargo_bin()) + .args([ + "--config", cfg_path.to_str().unwrap(), + "search", "rust", "--json", + ]) + .output() + .expect("run"); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(v.get("trace").is_none(), "trace field absent when --trace not passed"); +} + +#[test] +fn search_trace_lexical_mode_empty_vector_list() { + let (_root, cfg_path) = temp_kebab_root(); + ingest_fixture(&cfg_path, "doc1.md", "# Title\n\nrust async hello\n"); + + let out = Command::new(cargo_bin()) + .args([ + "--config", cfg_path.to_str().unwrap(), + "search", "rust", "--trace", "--mode", "lexical", "--json", + ]) + .output() + .expect("run"); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["trace"]["vector"].as_array().unwrap().len(), 0); + assert_eq!(v["trace"]["timing"]["vector_ms"], 0); +} +``` + +- [ ] **Step 2: Write failing integration tests for stats** + +Create `crates/kebab-cli/tests/wire_schema_breakdowns.rs`: + +```rust +//! p9-fb-37: integration tests for `kebab schema --json` extended stats. + +use std::process::Command; + +mod common; +use common::{cargo_bin, ingest_fixture, temp_kebab_root}; + +#[test] +fn schema_stats_includes_breakdowns_on_fresh_corpus() { + let (_root, cfg_path) = temp_kebab_root(); + // Fresh init — no docs. We need migrations to have run; the + // first search/ingest call brings them up. Run an empty schema + // query on a freshly-init'd config: + Command::new(cargo_bin()) + .args(["--config", cfg_path.to_str().unwrap(), "init"]) + .output() + .expect("init"); + + let out = Command::new(cargo_bin()) + .args(["--config", cfg_path.to_str().unwrap(), "schema", "--json"]) + .output() + .expect("run"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let stats = &v["stats"]; + // 5 keys padded. + let m = stats["media_breakdown"].as_object().unwrap(); + assert_eq!(m.len(), 5); + for k in &["markdown", "pdf", "image", "audio", "other"] { + assert_eq!(m[*k], 0); + } + // lang_breakdown empty {}. + assert_eq!(stats["lang_breakdown"].as_object().unwrap().len(), 0); + // index_bytes shape. + assert!(stats["index_bytes"]["sqlite"].is_number()); + assert!(stats["index_bytes"]["lancedb"].is_number()); + assert_eq!(stats["stale_doc_count"], 0); +} + +#[test] +fn schema_stats_breakdowns_after_ingest() { + let (_root, cfg_path) = temp_kebab_root(); + ingest_fixture(&cfg_path, "a.md", "---\nlang: en\n---\nhello\n"); + ingest_fixture(&cfg_path, "b.md", "---\nlang: ko\n---\n안녕\n"); + + let out = Command::new(cargo_bin()) + .args(["--config", cfg_path.to_str().unwrap(), "schema", "--json"]) + .output() + .expect("run"); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let stats = &v["stats"]; + assert_eq!(stats["media_breakdown"]["markdown"], 2); + assert_eq!(stats["lang_breakdown"]["en"], 1); + assert_eq!(stats["lang_breakdown"]["ko"], 1); + assert!(stats["index_bytes"]["sqlite"].as_u64().unwrap() > 0); +} +``` + +- [ ] **Step 3: Verify or create `tests/common/mod.rs`** + +Check existing tests for shared `common` module: +```bash +ls crates/kebab-cli/tests/ +cat crates/kebab-cli/tests/common/mod.rs 2>/dev/null +``` + +If `common` module exists with `cargo_bin`, `ingest_fixture`, `temp_kebab_root`, reuse. If not, mirror functions from `wire_search_filters.rs` (the fb-36 integration test) — copy its fixture helpers to `crates/kebab-cli/tests/common/mod.rs` and reference via `mod common`. + +- [ ] **Step 4: Run integration tests** + +```bash +cargo test -p kebab-cli --test wire_search_trace +cargo test -p kebab-cli --test wire_schema_breakdowns +``` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-cli/tests/wire_search_trace.rs crates/kebab-cli/tests/wire_schema_breakdowns.rs crates/kebab-cli/tests/common/mod.rs +git commit -m "test(cli): integration tests for --trace + schema breakdowns (fb-37)" +``` + +--- + +## Task 8: MCP SearchInput trace + integration test + +**Files:** +- Modify: `crates/kebab-mcp/src/tools/search.rs` +- Create: `crates/kebab-mcp/tests/tools_call_search_trace.rs` + +- [ ] **Step 1: Write failing integration test** + +Create `crates/kebab-mcp/tests/tools_call_search_trace.rs`. Mirror existing `tools_call_search.rs` fixture pattern (read it first): + +```rust +//! p9-fb-37: MCP search trace input/output integration. + +use serde_json::json; + +mod common; +use common::call_tool_with_temp_corpus; + +#[test] +fn search_with_trace_true_returns_trace_field() { + let v = call_tool_with_temp_corpus( + "kebab__search", + json!({"query": "rust", "trace": true}), + ); + assert!(v["trace"].is_object(), "trace field present when trace:true"); + assert!(v["trace"]["timing"]["total_ms"].is_number()); +} + +#[test] +fn search_without_trace_omits_field() { + let v = call_tool_with_temp_corpus( + "kebab__search", + json!({"query": "rust"}), + ); + assert!(v.get("trace").is_none(), "trace absent when not requested"); +} + +#[test] +fn search_with_trace_false_omits_field() { + let v = call_tool_with_temp_corpus( + "kebab__search", + json!({"query": "rust", "trace": false}), + ); + assert!(v.get("trace").is_none()); +} +``` + +If `tests/common/mod.rs` lacks `call_tool_with_temp_corpus`, derive from existing test fixtures. Pattern: spin up `kebab_mcp::Server`, send tools/call request, return result `serde_json::Value`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p kebab-mcp --test tools_call_search_trace +``` +Expected: compile error — `SearchInput.trace` field absent. + +- [ ] **Step 3: Add `trace` to `SearchInput`** + +Modify `crates/kebab-mcp/src/tools/search.rs`. Find `pub struct SearchInput` (~line 30-50). Add at end: + +```rust + /// p9-fb-37: when true, capture pipeline trace and include in + /// response. Bypasses cache. Default false. + #[serde(default)] + pub trace: Option, +``` + +- [ ] **Step 4: Wire `trace` into dispatch** + +Find the dispatch body where `SearchOpts` is constructed (~line 90-130). Add: + +```rust +let opts = kebab_core::SearchOpts { + max_tokens: input.max_tokens, + snippet_chars: input.snippet_chars, + cursor: input.cursor.clone(), + trace: input.trace.unwrap_or(false), +}; +``` + +(The existing struct construction may not include `cursor` etc — adapt to what's actually present, just add `trace:` line.) + +The output JSON should already pick up `trace` because the wire helper inherits from the same `SearchResponse` shape. Verify by searching for how the MCP tool serializes its response — check whether it uses `kebab_cli::wire::wire_search_response` or its own builder. + +```bash +grep -n "wire_search_response\|search_response.v1\|SearchResponse" crates/kebab-mcp/src/tools/search.rs +``` + +If MCP uses its own builder, mirror the trace-injection pattern from Task 6 Step 3. + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p kebab-mcp --test tools_call_search_trace +``` +Expected: all 3 pass. + +- [ ] **Step 6: Clippy** + +```bash +cargo clippy -p kebab-mcp --all-targets -- -D warnings +``` + +- [ ] **Step 7: Commit** + +```bash +git add crates/kebab-mcp/src/tools/search.rs crates/kebab-mcp/tests/tools_call_search_trace.rs +git commit -m "feat(mcp): kebab__search trace input + output mirror (fb-37)" +``` + +--- + +## Task 9: TUI search pane `t` keystroke + TracePopup + +**Files:** +- Create: `crates/kebab-tui/src/trace_popup.rs` +- Modify: `crates/kebab-tui/src/lib.rs` +- Modify: `crates/kebab-tui/src/app.rs` +- Modify: `crates/kebab-tui/src/search.rs` +- Modify: `crates/kebab-tui/src/cheatsheet.rs` + +- [ ] **Step 1: Create `trace_popup.rs`** + +```rust +//! p9-fb-37: TUI trace popup. Opens from Search pane via `t` key +//! when results are visible. Re-runs the current query with +//! `SearchOpts.trace = true` and displays the lex / vec / rrf union +//! + per-stage timing as a single scroll list. + +use crossterm::event::{KeyCode, KeyEvent}; +use kebab_core::SearchTrace; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +#[derive(Debug, Clone)] +pub struct TracePopupState { + pub trace: SearchTrace, + pub scroll: u16, +} + +impl TracePopupState { + pub fn new(trace: SearchTrace) -> Self { + Self { trace, scroll: 0 } + } +} + +pub fn render_trace_popup(f: &mut Frame, area: Rect, state: &TracePopupState) { + let mut lines: Vec = Vec::new(); + let bold = Style::default().add_modifier(Modifier::BOLD); + + lines.push(Line::from(Span::styled( + format!( + "Lexical ({} hits, {} ms)", + state.trace.lexical.len(), + state.trace.timing.lexical_ms, + ), + bold, + ))); + for c in &state.trace.lexical { + lines.push(Line::from(format!( + " #{:>2} score={:.4} chunk={}", + c.rank, c.score, c.chunk_id.0 + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + "Vector ({} hits, {} ms)", + state.trace.vector.len(), + state.trace.timing.vector_ms, + ), + bold, + ))); + for c in &state.trace.vector { + lines.push(Line::from(format!( + " #{:>2} score={:.4} chunk={}", + c.rank, c.score, c.chunk_id.0 + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + "RRF inputs ({} entries, {} ms fusion)", + state.trace.rrf_inputs.len(), + state.trace.timing.fusion_ms, + ), + bold, + ))); + for e in &state.trace.rrf_inputs { + lines.push(Line::from(format!( + " chunk={} lex={:?} vec={:?} fusion={:.4}", + e.chunk_id.0, e.lexical_rank, e.vector_rank, e.fusion_score + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("Total: {} ms", state.trace.timing.total_ms), + bold, + ))); + + let block = Block::default() + .title("Trace — Esc to close, j/k or ↑↓ to scroll") + .borders(Borders::ALL); + let p = Paragraph::new(lines) + .block(block) + .scroll((state.scroll, 0)) + .wrap(Wrap { trim: false }); + f.render_widget(p, area); +} + +/// Handle keys while popup is open. Returns true if the popup should +/// close. +pub fn handle_key_trace_popup(state: &mut TracePopupState, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => true, + KeyCode::Char('j') | KeyCode::Down => { + state.scroll = state.scroll.saturating_add(1); + false + } + KeyCode::Char('k') | KeyCode::Up => { + state.scroll = state.scroll.saturating_sub(1); + false + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyModifiers; + use kebab_core::TraceTiming; + + fn dummy_state() -> TracePopupState { + TracePopupState::new(SearchTrace { + lexical: vec![], + vector: vec![], + rrf_inputs: vec![], + timing: TraceTiming::default(), + }) + } + + #[test] + fn esc_closes() { + let mut s = dummy_state(); + assert!(handle_key_trace_popup( + &mut s, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + )); + } + + #[test] + fn j_scrolls_down() { + let mut s = dummy_state(); + assert!(!handle_key_trace_popup( + &mut s, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + )); + assert_eq!(s.scroll, 1); + } +} +``` + +- [ ] **Step 2: Register module + state** + +Modify `crates/kebab-tui/src/lib.rs`: +```rust +pub mod trace_popup; +``` + +Modify `crates/kebab-tui/src/app.rs`. Find `pub struct App` (~line 1-100). Add field: +```rust + /// p9-fb-37: trace popup state, `Some` while open. + pub trace_popup: Option, +``` + +Initialize in `App::new` / `App::default` to `None`. + +- [ ] **Step 3: Wire `t` keystroke in search pane** + +Modify `crates/kebab-tui/src/search.rs` `pub fn handle_key_search` (~line 196). Add a key arm in the match block before existing arms: + +```rust + (KeyCode::Char('t'), KeyModifiers::NONE) + if !state.results.is_empty() && state.trace_popup.is_none() => + { + // Re-run current query with trace enabled. + let cfg = match kebab_config::Config::load(state.config_path.as_deref()) { + Ok(c) => c, + Err(_) => return KeyOutcome::Consumed, + }; + let q = kebab_core::SearchQuery { + text: state.query.clone(), + mode: state.mode, + k: state.k, + filters: state.filters.clone(), + }; + let opts = kebab_core::SearchOpts { + trace: true, + ..Default::default() + }; + if let Ok(resp) = kebab_app::search_with_opts_with_config(cfg, q, opts) { + if let Some(t) = resp.trace { + state.trace_popup = Some(crate::trace_popup::TracePopupState::new(t)); + } + } + KeyOutcome::Consumed + } +``` + +Engineer note: field names (`state.results`, `state.query`, `state.mode`, `state.k`, `state.filters`, `state.config_path`) must match actual `App` struct. Inspect `kebab-tui/src/app.rs` and adapt — if some are absent (e.g. `config_path`), fall back to `kebab_config::Config::load(None)`. + +- [ ] **Step 4: Render popup + handle popup keys in main loop** + +Find the main render loop (in `crates/kebab-tui/src/run.rs` or `app.rs`) — wherever `render_search` / `render_inspect` are conditionally called. Add a render check: if `state.trace_popup.is_some()`, draw the popup overlay. Pattern: + +```rust +if let Some(popup) = &state.trace_popup { + let popup_area = centered_rect(80, 80, frame.area()); + crate::trace_popup::render_trace_popup(frame, popup_area, popup); +} +``` + +`centered_rect` helper may already exist (commonly in `app.rs` or `terminal.rs`). If not, define it inline: + +```rust +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} +``` + +In key dispatch, intercept popup keys first: + +```rust +if let Some(popup) = state.trace_popup.as_mut() { + if crate::trace_popup::handle_key_trace_popup(popup, key) { + state.trace_popup = None; + } + return KeyOutcome::Consumed; +} +``` + +Place before the per-pane key dispatch. + +- [ ] **Step 5: Update cheatsheet** + +Modify `crates/kebab-tui/src/cheatsheet.rs`. Find the search pane keybind list (search for "Search" header or `i = inspect`). Add: + +```rust + "t = trace", +``` + +(Exact insertion depends on cheatsheet's data structure — array of strings, struct rows, etc. Adapt.) + +- [ ] **Step 6: Run TUI tests** + +```bash +cargo test -p kebab-tui +``` +Expected: 2 new trace_popup tests pass; existing TUI tests unaffected. + +- [ ] **Step 7: Clippy** + +```bash +cargo clippy -p kebab-tui --all-targets -- -D warnings +``` + +- [ ] **Step 8: Commit** + +```bash +git add crates/kebab-tui/src/trace_popup.rs crates/kebab-tui/src/lib.rs \ + crates/kebab-tui/src/app.rs crates/kebab-tui/src/search.rs \ + crates/kebab-tui/src/cheatsheet.rs crates/kebab-tui/src/run.rs +git commit -m "feat(tui): search pane t-key opens TracePopup (fb-37)" +``` + +--- + +## Task 10: Wire schema docs + README + SMOKE + INDEX + SKILL + status flip + +**Files:** +- Modify: `docs/wire-schema/v1/search_response.schema.json` +- Modify: `docs/wire-schema/v1/schema.schema.json` +- Modify: `README.md` +- Modify: `docs/SMOKE.md` +- Modify: `tasks/p9/p9-fb-37-trace-and-stats.md` +- Modify: `tasks/INDEX.md` +- Modify: `integrations/claude-code/kebab/SKILL.md` + +- [ ] **Step 1: Update `search_response.schema.json`** + +Add `trace` to `properties` (NOT to `required`): + +```json +"trace": { + "type": "object", + "description": "p9-fb-37: present iff caller passed --trace / SearchOpts.trace=true. Lex/vec pre-fusion lists + RRF union + per-stage timing.", + "required": ["lexical", "vector", "rrf_inputs", "timing"], + "properties": { + "lexical": { "type": "array", "items": { "type": "object" } }, + "vector": { "type": "array", "items": { "type": "object" } }, + "rrf_inputs":{ "type": "array", "items": { "type": "object" } }, + "timing": { + "type": "object", + "required": ["lexical_ms", "vector_ms", "fusion_ms", "total_ms"], + "properties": { + "lexical_ms": { "type": "integer", "minimum": 0 }, + "vector_ms": { "type": "integer", "minimum": 0 }, + "fusion_ms": { "type": "integer", "minimum": 0 }, + "total_ms": { "type": "integer", "minimum": 0 } + } + } + } +} +``` + +- [ ] **Step 2: Update `schema.schema.json`** + +In `properties.stats.properties`, add the four new fields: + +```json +"media_breakdown": { + "type": "object", + "description": "p9-fb-37: per-media-kind doc count. 5 keys (markdown/pdf/image/audio/other), zero-padded.", + "additionalProperties": { "type": "integer", "minimum": 0 } +}, +"lang_breakdown": { + "type": "object", + "description": "p9-fb-37: per-language doc count. NULL lang keyed as the literal string 'null'. Map may be empty on empty corpus.", + "additionalProperties": { "type": "integer", "minimum": 0 } +}, +"index_bytes": { + "type": "object", + "description": "p9-fb-37: on-disk byte sums.", + "required": ["sqlite", "lancedb"], + "properties": { + "sqlite": { "type": "integer", "minimum": 0 }, + "lancedb": { "type": "integer", "minimum": 0 } + } +}, +"stale_doc_count": { + "type": "integer", + "minimum": 0, + "description": "p9-fb-37: docs whose updated_at exceeds config.search.stale_threshold_days. 0 when threshold=0." +} +``` + +- [ ] **Step 3: Update `README.md`** + +Find the `kebab search` row in the command table. Add `--trace` to its flag list. Find the `kebab schema` row — extend its description with one phrase like "+ media/lang/bytes/stale breakdowns (fb-37)". + +- [ ] **Step 4: Update `docs/SMOKE.md`** + +Add a new section after the fb-36 walkthrough: + +```markdown +### Trace + stats (fb-37) + +Re-run a search with `--trace` to see per-stage candidate lists + timing: + +```bash +kebab --config /tmp/kebab-smoke/config.toml search "rust async" --trace --json | jq .trace +``` + +Inspect the corpus health surface: + +```bash +kebab --config /tmp/kebab-smoke/config.toml schema --json | jq .stats +``` + +Look for: `media_breakdown` (5 keys), `lang_breakdown`, `index_bytes`, `stale_doc_count`. +``` + +- [ ] **Step 5: Update `tasks/p9/p9-fb-37-trace-and-stats.md`** + +Flip the frontmatter `status: open` → `status: completed`. Add at the top (after the existing skeleton banner) a "Design + plan" links block: + +```markdown +- Design: [`docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md) +- Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-37-trace-and-stats.md) +``` + +- [ ] **Step 6: Update `tasks/INDEX.md`** + +Find the fb-37 row. Flip the status column to ✅. + +- [ ] **Step 7: Update `integrations/claude-code/kebab/SKILL.md`** + +Find the `mcp__kebab__search` input shape block. Append a `trace: null` field. Add a sentence under the search inputs bullet list noting that `trace: true` returns a `trace` block on the response with pre-fusion lex/vec lists + per-stage timing, and that trace bypasses the search cache. Also update the schema bullet list to mention the new stats sub-fields. + +- [ ] **Step 8: Run full workspace tests + clippy** + +```bash +cargo test --workspace --no-fail-fast -j 1 +cargo clippy --workspace --all-targets -- -D warnings +``` +Expected: all green. + +- [ ] **Step 9: Commit** + +```bash +git add docs/ README.md tasks/p9/p9-fb-37-trace-and-stats.md tasks/INDEX.md integrations/claude-code/kebab/SKILL.md +git commit -m "docs(fb-37): wire schema + README + SMOKE + INDEX + SKILL" +``` + +--- + +## Final verification checklist + +- [ ] `cargo test --workspace --no-fail-fast -j 1` green +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean +- [ ] Manual smoke against `/tmp/kebab-smoke`: + - [ ] `kebab search Q --trace --json | jq .trace` shows lex/vec/rrf/timing + - [ ] `kebab search Q --json` does NOT include `trace` + - [ ] `kebab schema --json | jq .stats` shows 4 new fields +- [ ] README, SMOKE, SKILL, INDEX, spec status all updated