Files
kebab/docs/superpowers/plans/2026-05-10-p9-fb-42-bulk-multi-query.md
th-kim0823 de9016fe16 plan(fb-42): bulk multi-query implementation plan
8 tasks: kebab-core types, kebab-app bulk_search_with_config facade
(cap 100 + per-query error policy), CLI --bulk flag + stdin ndjson +
output stream, CLI integration tests, MCP bulk_search tool +
registration + tools_list count bump, MCP integration tests,
capability flag, wire schemas + README + SMOKE + design + SKILL +
status flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:10:39 +09:00

45 KiB

fb-42 Bulk Multi-Query 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: Add bulk multi-query surface — kebab search --bulk (CLI stdin ndjson) + mcp__kebab__bulk_search tool — so agents can issue N queries per round-trip with the App instance / cache reused.

Architecture: kebab-core domain types (BulkSearchItem / Summary / Response). kebab-app bulk_search_with_config facade runs sequential for-loop, reusing one App. CLI parses stdin ndjson and emits per-query stdout ndjson + summary stderr. MCP tool wraps the same facade with a JSON envelope. Per-query failures embed error.v1 in their item (continue, no abort). Caps at 100 queries per call.

Tech Stack: Rust 2024, serde, serde_json, anyhow, JSON Schema 2020-12.

Spec: docs/superpowers/specs/2026-05-10-p9-fb-42-bulk-multi-query-design.md


File map

Create:

  • crates/kebab-app/src/bulk.rsbulk_search_with_config facade.
  • crates/kebab-cli/tests/wire_bulk_search.rs — CLI integration tests.
  • crates/kebab-mcp/src/tools/bulk_search.rs — MCP tool handler.
  • crates/kebab-mcp/tests/tools_call_bulk_search.rs — MCP integration tests.
  • docs/wire-schema/v1/bulk_search_item.schema.json — per-query result schema.
  • docs/wire-schema/v1/bulk_search_response.schema.json — MCP envelope schema.

Modify:

  • crates/kebab-core/src/search.rsBulkSearchItem / BulkSearchSummary / BulkSearchResponse types.
  • crates/kebab-core/src/lib.rs — re-export new types.
  • crates/kebab-app/src/lib.rs — register bulk module + re-export facade.
  • crates/kebab-app/src/schema.rs — add bulk_search: bool to Capabilities + snapshot value true.
  • crates/kebab-cli/src/main.rs — add --bulk flag to Cmd::Search + dispatch branch + stdin reader + ndjson output.
  • crates/kebab-cli/src/wire.rswire_bulk_search_item helper.
  • crates/kebab-mcp/src/tools/mod.rspub mod bulk_search;.
  • crates/kebab-mcp/src/lib.rsbuild_tools_vec adds bulk_search entry; call_tool adds "bulk_search" => arm.
  • crates/kebab-mcp/tests/tools_list.rs — count 7 → 8.
  • README.mdkebab search --bulk row + example line.
  • docs/SMOKE.md — bulk walkthrough section.
  • docs/superpowers/specs/2026-04-27-kebab-final-form-design.md §4 — bulk subsection.
  • integrations/claude-code/kebab/SKILL.mdmcp__kebab__bulk_search tool description.
  • tasks/p9/p9-fb-42-bulk-multi-query-rerank.md — flip status, link design + plan, "rerank hint deferred" note.
  • tasks/INDEX.md — fb-42 row .

Task 1: kebab-core domain types

Files:

  • Modify: crates/kebab-core/src/search.rs

  • Modify: crates/kebab-core/src/lib.rs

  • Step 1: Append failing tests to mod tests

#[test]
fn bulk_search_summary_serde_roundtrip() {
    let s = BulkSearchSummary { total: 5, succeeded: 4, failed: 1 };
    let v = serde_json::to_value(s).unwrap();
    assert_eq!(v["total"], 5);
    assert_eq!(v["succeeded"], 4);
    assert_eq!(v["failed"], 1);
    let back: BulkSearchSummary = serde_json::from_value(v).unwrap();
    assert_eq!(back, s);
}

#[test]
fn bulk_search_summary_default_is_zeros() {
    let s = BulkSearchSummary::default();
    assert_eq!(s.total, 0);
    assert_eq!(s.succeeded, 0);
    assert_eq!(s.failed, 0);
}

#[test]
fn bulk_search_item_serde_response_variant() {
    let item = BulkSearchItem {
        query: serde_json::json!({"query": "rust"}),
        response: Some(serde_json::json!({"hits": []})),
        error: None,
    };
    let v = serde_json::to_value(&item).unwrap();
    assert!(v["response"].is_object());
    assert!(v["error"].is_null());
}

#[test]
fn bulk_search_item_serde_error_variant() {
    let item = BulkSearchItem {
        query: serde_json::json!({"query": "rust"}),
        response: None,
        error: Some(serde_json::json!({"code": "config_invalid", "message": "bad"})),
    };
    let v = serde_json::to_value(&item).unwrap();
    assert!(v["response"].is_null());
    assert_eq!(v["error"]["code"], "config_invalid");
}
  • Step 2: Run tests to verify compile errors
cargo test -p kebab-core --lib bulk_search

Expected: errors — types undefined.

  • Step 3: Add domain types in crates/kebab-core/src/search.rs

After existing IndexBytes (or wherever fb-37 fb-38 types live, near end of types):

/// p9-fb-42: per-query result in bulk search. `response` XOR `error` —
/// exactly one is `Some`. `query` is the input echo (raw JSON value)
/// so consumers can correlate input to output without index tracking.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BulkSearchItem {
    pub query: serde_json::Value,
    pub response: Option<serde_json::Value>,
    pub error: Option<serde_json::Value>,
}

/// p9-fb-42: bulk summary counts. Invariant: total == succeeded + failed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BulkSearchSummary {
    pub total: u32,
    pub succeeded: u32,
    pub failed: u32,
}

/// p9-fb-42: MCP-only envelope. CLI emits raw ndjson without envelope.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BulkSearchResponse {
    pub schema_version: String,
    pub results: Vec<BulkSearchItem>,
    pub summary: BulkSearchSummary,
}

schema_version is a runtime String (not const) so the constructor can stamp "bulk_search_response.v1" consistent with the existing kebab-app::schema::SchemaV1 pattern.

  • Step 4: Re-export in crates/kebab-core/src/lib.rs

Find the search:: re-export block (the one fb-37 + fb-38 already extended with SearchTrace, IndexBytes, MEDIA_KINDS, ScoreKind). Add BulkSearchItem, BulkSearchSummary, BulkSearchResponse to the same export list.

grep -n "SearchTrace\|ScoreKind\|MEDIA_KINDS" crates/kebab-core/src/lib.rs
  • Step 5: Run tests + clippy
cargo test -p kebab-core --lib
cargo clippy -p kebab-core --all-targets -- -D warnings

Expected: 4 new tests pass, existing tests untouched, clippy clean.

  • Step 6: Commit
git add crates/kebab-core/src/search.rs crates/kebab-core/src/lib.rs
git commit -m "feat(core): BulkSearchItem / Summary / Response types (fb-42)"

Task 2: kebab-app bulk facade

Files:

  • Create: crates/kebab-app/src/bulk.rs

  • Modify: crates/kebab-app/src/lib.rs

  • Step 1: Inspect existing facade pattern

grep -n "search_with_opts_with_config\|App::open_with_config\|pub fn search_with" crates/kebab-app/src/lib.rs | head -10

Read App::search_with_opts body in crates/kebab-app/src/app.rs (~line 306) for the SearchOpts → SearchQuery → search flow.

  • Step 2: Create crates/kebab-app/src/bulk.rs
//! p9-fb-42: bulk multi-query facade. Sequential for-loop reusing
//! one App instance so embedder cold-start + LRU cache amortize
//! across the N queries.

use anyhow::Context;
use kebab_core::{
    BulkSearchItem, BulkSearchSummary, ChunkId, DocumentId, Lang,
    SearchFilters, SearchMode, SearchOpts, SearchQuery, TrustLevel, WorkspacePath,
};
use serde_json::Value;

use crate::App;

/// Hard cap on items per bulk call. Documented in spec — agents that
/// hit this should batch-split.
pub const BULK_QUERIES_MAX: usize = 100;

/// p9-fb-42: bulk search facade. Returns `(items, summary)` always
/// — per-query failures embed `error.v1` JSON in the item rather
/// than aborting the bulk call. Returns `Err` only for input
/// validation failures (e.g. >100 queries).
#[doc(hidden)]
pub fn bulk_search_with_config(
    config: kebab_config::Config,
    raw_items: Vec<Value>,
) -> anyhow::Result<(Vec<BulkSearchItem>, BulkSearchSummary)> {
    if raw_items.len() > BULK_QUERIES_MAX {
        anyhow::bail!(
            "queries: max {} items, got {}",
            BULK_QUERIES_MAX,
            raw_items.len()
        );
    }

    let app = App::open_with_config(config).context("kebab-app: open for bulk_search")?;

    let mut results: Vec<BulkSearchItem> = Vec::with_capacity(raw_items.len());
    let mut succeeded: u32 = 0;
    let mut failed: u32 = 0;

    for raw in raw_items {
        let item = run_one(&app, raw);
        if item.error.is_some() {
            failed += 1;
        } else {
            succeeded += 1;
        }
        results.push(item);
    }

    let summary = BulkSearchSummary {
        total: succeeded + failed,
        succeeded,
        failed,
    };
    Ok((results, summary))
}

fn run_one(app: &App, raw: Value) -> BulkSearchItem {
    let echo = raw.clone();
    match parse_one(&raw) {
        Ok((query, opts)) => match app.search_with_opts(query, opts) {
            Ok(resp) => {
                let resp_v = serde_json::to_value(&resp).unwrap_or(Value::Null);
                BulkSearchItem {
                    query: echo,
                    response: Some(resp_v),
                    error: None,
                }
            }
            Err(e) => BulkSearchItem {
                query: echo,
                response: None,
                error: Some(error_v1_json("retrieval_error", &format!("{e:#}"), None)),
            },
        },
        Err(msg) => BulkSearchItem {
            query: echo,
            response: None,
            error: Some(error_v1_json("invalid_input", &msg, None)),
        },
    }
}

fn parse_one(raw: &Value) -> Result<(SearchQuery, SearchOpts), String> {
    let obj = raw.as_object().ok_or("expected JSON object")?;
    let text = obj
        .get("query")
        .and_then(|v| v.as_str())
        .ok_or("missing required field: query")?
        .to_string();

    let mode = match obj.get("mode").and_then(|v| v.as_str()) {
        None => SearchMode::Hybrid,
        Some("hybrid") => SearchMode::Hybrid,
        Some("lexical") => SearchMode::Lexical,
        Some("vector") => SearchMode::Vector,
        Some(other) => return Err(format!("invalid mode: {other:?}")),
    };

    let k = obj
        .get("k")
        .and_then(|v| v.as_u64())
        .map(|n| n as usize)
        .unwrap_or(0); // 0 → use config default in app

    let trust_min = match obj.get("trust_min").and_then(|v| v.as_str()) {
        None => None,
        Some("primary") => Some(TrustLevel::Primary),
        Some("secondary") => Some(TrustLevel::Secondary),
        Some("generated") => Some(TrustLevel::Generated),
        Some(other) => return Err(format!("invalid trust_min: {other:?}")),
    };

    let ingested_after = match obj.get("ingested_after").and_then(|v| v.as_str()) {
        None => None,
        Some(s) => Some(
            time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
                .map_err(|e| format!("invalid ingested_after RFC3339 {s:?}: {e}"))?,
        ),
    };

    let media: Vec<String> = obj
        .get("media")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|x| x.as_str().map(normalize_media_alias))
                .collect()
        })
        .unwrap_or_default();

    let tags_any: Vec<String> = obj
        .get("tag")
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
        .unwrap_or_default();

    let lang = obj
        .get("lang")
        .and_then(|v| v.as_str())
        .map(|s| Lang(s.to_string()));

    let path_glob = obj
        .get("path_glob")
        .and_then(|v| v.as_str())
        .map(String::from);

    let doc_id = obj
        .get("doc_id")
        .and_then(|v| v.as_str())
        .map(|s| DocumentId(s.to_string()));

    let filters = SearchFilters {
        tags_any,
        lang,
        path_glob,
        trust_min,
        media,
        ingested_after,
        doc_id,
    };

    let opts = SearchOpts {
        max_tokens: obj.get("max_tokens").and_then(|v| v.as_u64()).map(|n| n as usize),
        snippet_chars: obj
            .get("snippet_chars")
            .and_then(|v| v.as_u64())
            .map(|n| n as usize),
        cursor: obj.get("cursor").and_then(|v| v.as_str()).map(String::from),
        trace: obj.get("trace").and_then(|v| v.as_bool()).unwrap_or(false),
    };

    Ok((SearchQuery { text, mode, k, filters }, opts))
}

fn normalize_media_alias(s: &str) -> String {
    match s.to_ascii_lowercase().as_str() {
        "md" => "markdown".to_string(),
        other => other.to_string(),
    }
}

fn error_v1_json(code: &str, message: &str, hint: Option<&str>) -> Value {
    serde_json::json!({
        "schema_version": "error.v1",
        "code": code,
        "message": message,
        "hint": hint,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn open_temp() -> kebab_config::Config {
        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 SqliteStore::open_existing succeeds inside App::open.
        let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
        store.run_migrations().unwrap();
        drop(store);
        // Leak the tempdir into a static — tests are short-lived; not worth threading.
        std::mem::forget(dir);
        cfg
    }

    #[test]
    fn empty_input_returns_empty_summary() {
        let cfg = open_temp();
        let (items, summary) = bulk_search_with_config(cfg, vec![]).unwrap();
        assert!(items.is_empty());
        assert_eq!(summary.total, 0);
        assert_eq!(summary.succeeded, 0);
        assert_eq!(summary.failed, 0);
    }

    #[test]
    fn over_cap_returns_err() {
        let cfg = open_temp();
        let raw: Vec<Value> = (0..101)
            .map(|_| serde_json::json!({"query": "x"}))
            .collect();
        let err = bulk_search_with_config(cfg, raw).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("max 100"));
    }

    #[test]
    fn invalid_item_emits_error_keeps_total_count() {
        let cfg = open_temp();
        let raw = vec![
            serde_json::json!({"query": "ok"}),
            serde_json::json!({"mode": "lexical"}),  // missing required `query`
        ];
        let (items, summary) = bulk_search_with_config(cfg, raw).unwrap();
        assert_eq!(items.len(), 2);
        assert_eq!(summary.total, 2);
        // First item: lexical mode against empty corpus succeeds with empty hits.
        assert!(items[0].error.is_none());
        // Second item: missing required field.
        assert!(items[1].error.is_some());
        assert_eq!(items[1].error.as_ref().unwrap()["code"], "invalid_input");
    }
}
  • Step 3: Register module + re-export facade

In crates/kebab-app/src/lib.rs, find the existing mod app; / mod fetch; etc. block. Add:

mod bulk;

In the pub use block (or near search_with_opts_with_config re-export), add:

#[doc(hidden)]
pub use bulk::{bulk_search_with_config, BULK_QUERIES_MAX};
  • Step 4: Run tests + clippy
cargo test -p kebab-app --lib bulk
cargo clippy -p kebab-app --all-targets -- -D warnings

Expected: 3 new tests pass; clippy clean.

  • Step 5: Commit
git add crates/kebab-app/src/bulk.rs crates/kebab-app/src/lib.rs
git commit -m "feat(app): bulk_search_with_config facade (fb-42)"

Task 3: CLI --bulk flag + stdin ndjson + output stream

Files:

  • Modify: crates/kebab-cli/src/main.rs

  • Modify: crates/kebab-cli/src/wire.rs

  • Step 1: Add wire_bulk_search_item helper to crates/kebab-cli/src/wire.rs

Append (after wire_search_response):

/// p9-fb-42: tag a `BulkSearchItem` (already serialized as a Value)
/// as `bulk_search_item.v1`. The inner `query` / `response` / `error`
/// fields stay verbatim — only the envelope gets the schema_version stamp.
pub fn wire_bulk_search_item(item: &kebab_core::BulkSearchItem) -> Value {
    let mut v = serde_json::to_value(item).expect("BulkSearchItem serializes");
    if let Value::Object(ref mut map) = v {
        map.insert(
            "schema_version".to_string(),
            Value::String("bulk_search_item.v1".to_string()),
        );
    }
    v
}
  • Step 2: Add --bulk flag to Cmd::Search in crates/kebab-cli/src/main.rs

Find Cmd::Search { ... } field block (around line 95-180 — fb-37 added trace, fb-38 added score_kind though that's not a flag). Append after the last field:

        /// p9-fb-42: bulk multi-query mode. Reads ndjson from stdin —
        /// one JSON object per line, each item shape mirrors the
        /// single-query input. Output is per-query ndjson on stdout
        /// (one `bulk_search_item.v1` per line) plus a summary line on
        /// stderr. Single-query flags (`--mode`, `--k`, `--tag`, etc.)
        /// are ignored when `--bulk` is set; pass them per-item in the
        /// stdin JSON instead. Caps at 100 queries per call.
        #[arg(long)]
        bulk: bool,
  • Step 3: Wire bulk dispatch in the Cmd::Search arm

Find the Cmd::Search { ... } => { ... } arm (~line 664). Add bulk, to the destructure pattern. Near the top of the arm body (before single-query SearchOpts construction), branch on *bulk:

            // p9-fb-42: bulk mode — stdin ndjson → bulk_search_with_config
            // → stdout ndjson per query + stderr summary. Single-query
            // flags are ignored (each item supplies its own).
            if *bulk {
                use std::io::{BufRead, Write};

                let cfg = kebab_config::Config::load(cli.config.as_deref())?;

                let stdin = std::io::stdin();
                let stdin_locked = stdin.lock();
                let mut raw_items: Vec<serde_json::Value> = Vec::new();
                for (lineno, line) in stdin_locked.lines().enumerate() {
                    let line = line?;
                    if line.trim().is_empty() {
                        continue;
                    }
                    let v: serde_json::Value =
                        serde_json::from_str(&line).map_err(|e| {
                            anyhow::Error::new(
                                kebab_app::error_wire::StructuredError(kebab_app::ErrorV1 {
                                    schema_version: kebab_app::ERROR_V1_ID.to_string(),
                                    code: "config_invalid".to_string(),
                                    message: format!(
                                        "stdin ndjson line {} parse error: {e}",
                                        lineno + 1
                                    ),
                                    details: serde_json::Value::Null,
                                    hint: Some(
                                        "each line must be a JSON object with at least `query`"
                                            .to_string(),
                                    ),
                                }),
                            )
                        })?;
                    raw_items.push(v);
                }

                let (items, summary) =
                    kebab_app::bulk_search_with_config(cfg, raw_items)?;

                if cli.json {
                    let mut stdout = std::io::stdout().lock();
                    for item in &items {
                        let v = wire::wire_bulk_search_item(item);
                        writeln!(stdout, "{}", serde_json::to_string(&v)?)?;
                    }
                    eprintln!(
                        "bulk_summary: total={} succeeded={} failed={}",
                        summary.total, summary.succeeded, summary.failed,
                    );
                } else {
                    let mut stdout = std::io::stdout().lock();
                    for (idx, item) in items.iter().enumerate() {
                        writeln!(stdout, "# Query {}: {}", idx + 1, item.query)?;
                        if let Some(err) = &item.error {
                            writeln!(stdout, "error: {}", err)?;
                        } else if let Some(resp) = &item.response {
                            writeln!(stdout, "{}", serde_json::to_string_pretty(resp)?)?;
                        }
                        writeln!(stdout)?;
                    }
                    eprintln!(
                        "bulk_summary: total={} succeeded={} failed={}",
                        summary.total, summary.succeeded, summary.failed,
                    );
                }
                return Ok(());
            }

The kebab_app::ErrorV1 / error_wire::StructuredError / ERROR_V1_ID types should already be in scope from prior fb-27 / fb-34 wiring. Verify by reading the existing fb-36 error path in the same file (search for StructuredError).

  • Step 4: Run tests + clippy
cargo build -p kebab-cli
cargo clippy -p kebab-cli --all-targets -- -D warnings

Expected: clean compile + clippy clean. (No new tests yet — those land in Task 4.)

  • Step 5: Commit
git add crates/kebab-cli/src/main.rs crates/kebab-cli/src/wire.rs
git commit -m "feat(cli): kebab search --bulk flag + stdin ndjson + output stream (fb-42)"

Task 4: CLI integration tests

Files:

  • Create: crates/kebab-cli/tests/wire_bulk_search.rs

  • Step 1: Inspect common fixture pattern

head -40 crates/kebab-cli/tests/common/mod.rs

common::write_config(dir, threshold_days) returns (cfg, workspace, data). common::ingest(&cfg, &workspace) runs kebab ingest.

  • Step 2: Create test file with 5 integration tests
//! p9-fb-42: integration tests for `kebab search --bulk`.

mod common;

use serde_json::Value;
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};

fn cargo_bin() -> &'static str {
    env!("CARGO_BIN_EXE_kebab")
}

fn run_bulk_with_stdin(cfg: &std::path::Path, stdin_body: &str, json: bool) -> std::process::Output {
    let mut cmd = Command::new(cargo_bin());
    cmd.arg("--config").arg(cfg).arg("search").arg("--bulk");
    if json {
        cmd.arg("--json");
    }
    cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
    let mut child = cmd.spawn().expect("spawn kebab");
    {
        let mut sin = child.stdin.take().expect("stdin");
        sin.write_all(stdin_body.as_bytes()).expect("write stdin");
    }
    child.wait_with_output().expect("wait")
}

fn seed_workspace(workspace: &std::path::Path) {
    fs::write(workspace.join("a.md"), "# Alpha\n\nrust async hello").unwrap();
    fs::write(workspace.join("b.md"), "# Bravo\n\nbread and kebab").unwrap();
}

#[test]
fn two_query_bulk_emits_per_query_ndjson() {
    let dir = tempfile::tempdir().unwrap();
    let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
    seed_workspace(&workspace);
    common::ingest(&cfg, &workspace);

    let out = run_bulk_with_stdin(
        &cfg,
        "{\"query\":\"rust\",\"mode\":\"lexical\"}\n{\"query\":\"kebab\",\"mode\":\"lexical\"}\n",
        true,
    );
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    let stdout = String::from_utf8_lossy(&out.stdout);
    let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
    assert_eq!(lines.len(), 2, "expected 2 ndjson lines, got {lines:?}");
    for line in &lines {
        let v: Value = serde_json::from_str(line).expect("valid JSON line");
        assert_eq!(v["schema_version"], "bulk_search_item.v1");
        assert!(v["response"].is_object());
        assert!(v["error"].is_null());
    }
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("bulk_summary: total=2 succeeded=2 failed=0"),
        "stderr summary missing: {stderr}"
    );
}

#[test]
fn empty_stdin_returns_empty_results_with_zero_summary() {
    let dir = tempfile::tempdir().unwrap();
    let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
    seed_workspace(&workspace);
    common::ingest(&cfg, &workspace);

    let out = run_bulk_with_stdin(&cfg, "", true);
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.trim().is_empty(), "expected empty stdout, got: {stdout}");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("bulk_summary: total=0 succeeded=0 failed=0"));
}

#[test]
fn malformed_ndjson_line_emits_config_invalid_exit_2() {
    let dir = tempfile::tempdir().unwrap();
    let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
    seed_workspace(&workspace);
    common::ingest(&cfg, &workspace);

    let out = run_bulk_with_stdin(&cfg, "not json\n", true);
    assert_eq!(out.status.code(), Some(2), "expected exit 2");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("config_invalid") || stderr.contains("parse error"),
        "expected config_invalid in stderr: {stderr}");
}

#[test]
fn over_cap_input_emits_error_exit_2() {
    let dir = tempfile::tempdir().unwrap();
    let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
    seed_workspace(&workspace);
    common::ingest(&cfg, &workspace);

    let body: String = (0..101)
        .map(|_| "{\"query\":\"x\",\"mode\":\"lexical\"}\n")
        .collect();
    let out = run_bulk_with_stdin(&cfg, &body, true);
    // bulk_search_with_config returns Err(anyhow) — surfaces as exit 1 (anyhow chain)
    // or 2 if classified as config_invalid by error_wire. Accept either,
    // but message must mention `max 100`.
    assert!(out.status.code().is_some());
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("max 100"), "expected 'max 100' in stderr: {stderr}");
}

#[test]
fn invalid_item_field_emits_per_item_error_continues() {
    let dir = tempfile::tempdir().unwrap();
    let (cfg, workspace, _data) = common::write_config(dir.path(), 0);
    seed_workspace(&workspace);
    common::ingest(&cfg, &workspace);

    let out = run_bulk_with_stdin(
        &cfg,
        "{\"query\":\"rust\",\"mode\":\"lexical\"}\n{\"query\":\"x\",\"mode\":\"bogus\"}\n",
        true,
    );
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
    assert_eq!(lines.len(), 2);
    let v0: Value = serde_json::from_str(lines[0]).unwrap();
    let v1: Value = serde_json::from_str(lines[1]).unwrap();
    assert!(v0["error"].is_null());
    assert!(v1["error"].is_object());
    assert_eq!(v1["error"]["code"], "invalid_input");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("succeeded=1 failed=1"));
}
  • Step 3: Run tests
cargo test -p kebab-cli --test wire_bulk_search

Expected: 5 tests pass.

  • Step 4: Commit
git add crates/kebab-cli/tests/wire_bulk_search.rs
git commit -m "test(cli): integration tests for kebab search --bulk (fb-42)"

Task 5: MCP bulk_search tool

Files:

  • Create: crates/kebab-mcp/src/tools/bulk_search.rs

  • Modify: crates/kebab-mcp/src/tools/mod.rs

  • Modify: crates/kebab-mcp/src/lib.rs

  • Step 1: Create crates/kebab-mcp/src/tools/bulk_search.rs

//! `bulk_search` tool — wraps `kebab_app::bulk_search_with_config`.
//! Input: `{ queries: [<SearchInput shape>, ...] }`.
//! Output: `bulk_search_response.v1` envelope (results + summary).

use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct BulkSearchInput {
    /// Per-query inputs. Each item mirrors the single-query `search`
    /// tool's input shape — `query` is required, all other fields are
    /// optional and default to single-search defaults. Capped at 100
    /// items; exceeding returns an `invalid_input` tool error without
    /// running any query.
    pub queries: Vec<serde_json::Value>,
}

pub fn handle(state: &KebabAppState, input: BulkSearchInput) -> CallToolResult {
    let cfg_clone = (*state.config).clone();
    match kebab_app::bulk_search_with_config(cfg_clone, input.queries) {
        Ok((items, summary)) => {
            let tagged_items: Vec<serde_json::Value> = items
                .iter()
                .map(|it| {
                    let mut v = serde_json::to_value(it).unwrap_or(serde_json::Value::Null);
                    if let serde_json::Value::Object(ref mut map) = v {
                        map.insert(
                            "schema_version".to_string(),
                            serde_json::Value::String("bulk_search_item.v1".to_string()),
                        );
                    }
                    v
                })
                .collect();
            let envelope = serde_json::json!({
                "schema_version": "bulk_search_response.v1",
                "results": tagged_items,
                "summary": {
                    "total": summary.total,
                    "succeeded": summary.succeeded,
                    "failed": summary.failed,
                },
            });
            match serde_json::to_string(&envelope) {
                Ok(json) => to_tool_success(json),
                Err(e) => to_tool_error(&anyhow::anyhow!(e)),
            }
        }
        Err(e) => {
            // Cap-exceed and other validation failures surface here.
            // Map to invalid_input via to_tool_error chain.
            to_tool_error(&e)
        }
    }
}
  • Step 2: Register module

In crates/kebab-mcp/src/tools/mod.rs, add:

pub mod bulk_search;
  • Step 3: Register tool in build_tools_vec

In crates/kebab-mcp/src/lib.rs, find build_tools_vec (~line 33). Add a new Tool::new entry inside the vec![...] (place after fetch):

        Tool::new(
            "bulk_search",
            "Bulk multi-query search — N queries per call (cap 100). Each query mirrors the `search` input shape; returns `bulk_search_response.v1` with per-query results + summary. Sequential execution reuses one App instance so cache / embedder cold-start cost amortizes.",
            schema_for_type::<tools::bulk_search::BulkSearchInput>(),
        ),
  • Step 4: Add dispatch arm in call_tool

Find the match request.name.as_ref() block (~line 129). Add new arm after fetch:

            "bulk_search" => {
                let args = request.arguments.unwrap_or_default();
                self.spawn_tool(args, |state, input| {
                    tools::bulk_search::handle(&state, input)
                })
                .await
            }
  • Step 5: Bump tool count assertion

Modify crates/kebab-mcp/tests/tools_list.rs. Find assert_eq!(tools.len(), 7, ...) (line ~10). Change to:

    assert_eq!(tools.len(), 8, "expected exactly 8 tools, got {}", tools.len());

If the file also has assertions about specific tool names (e.g. a Vec containing ["schema", "doctor", ...]), add "bulk_search" to that list.

  • Step 6: Run tests + clippy
cargo test -p kebab-mcp --test tools_list
cargo clippy -p kebab-mcp --all-targets -- -D warnings

Expected: tools_list count test passes; clippy clean.

  • Step 7: Commit
git add crates/kebab-mcp/src/tools/bulk_search.rs crates/kebab-mcp/src/tools/mod.rs crates/kebab-mcp/src/lib.rs crates/kebab-mcp/tests/tools_list.rs
git commit -m "feat(mcp): kebab__bulk_search tool (fb-42)"

Task 6: MCP integration tests

Files:

  • Create: crates/kebab-mcp/tests/tools_call_bulk_search.rs

  • Step 1: Inspect existing MCP integration test pattern

head -80 crates/kebab-mcp/tests/tools_call_search.rs

Mirror minimal_config + setup pattern.

  • Step 2: Create crates/kebab-mcp/tests/tools_call_bulk_search.rs
//! p9-fb-42: integration tests for `mcp__kebab__bulk_search`.

use std::fs;

use kebab_config::Config;
use kebab_core::SourceScope;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
use serde_json::json;

fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
    let mut cfg = Config::defaults();
    cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
    cfg.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned();
    cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
    cfg.workspace.exclude.clear();
    cfg.models.embedding.provider = "none".to_string();
    cfg.models.embedding.dimensions = 0;
    cfg
}

fn setup() -> (tempfile::TempDir, KebabHandler) {
    let dir = tempfile::tempdir().unwrap();
    let data_dir = dir.path().join("data");
    let workspace_root = dir.path().join("notes");
    fs::create_dir_all(&data_dir).unwrap();
    fs::create_dir_all(&workspace_root).unwrap();
    let config = minimal_config(&data_dir, &workspace_root);
    fs::write(
        workspace_root.join("a.md"),
        "# Alpha\n\nThis document mentions kebab and bread.",
    )
    .unwrap();
    let scope = SourceScope { root: workspace_root.clone(), include: vec![], exclude: vec![] };
    let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
    let state = KebabAppState::new(config, None);
    let handler = KebabHandler::new(state);
    (dir, handler)
}

fn extract_json(result: &rmcp::model::CallToolResult) -> serde_json::Value {
    assert!(!result.is_error.unwrap_or(false), "expected isError=false, got {result:?}");
    let content = result.content.first().expect("at least one content item");
    let text = match &content.raw {
        RawContent::Text(t) => &t.text,
        other => panic!("expected Text content, got {other:?}"),
    };
    serde_json::from_str(text).expect("valid JSON")
}

#[tokio::test]
async fn bulk_search_two_queries_returns_envelope() {
    let (_dir, handler) = setup();
    let input = kebab_mcp::tools::bulk_search::BulkSearchInput {
        queries: vec![
            json!({"query": "kebab", "mode": "lexical", "k": 5}),
            json!({"query": "bread", "mode": "lexical", "k": 5}),
        ],
    };
    let result = kebab_mcp::tools::bulk_search::handle(handler.state(), input);
    let v = extract_json(&result);
    assert_eq!(v["schema_version"], "bulk_search_response.v1");
    let results = v["results"].as_array().expect("results array");
    assert_eq!(results.len(), 2);
    for r in results {
        assert_eq!(r["schema_version"], "bulk_search_item.v1");
        assert!(r["response"].is_object());
        assert!(r["error"].is_null());
    }
    assert_eq!(v["summary"]["total"], 2);
    assert_eq!(v["summary"]["succeeded"], 2);
    assert_eq!(v["summary"]["failed"], 0);
}

#[tokio::test]
async fn bulk_search_empty_queries_returns_empty_envelope() {
    let (_dir, handler) = setup();
    let input = kebab_mcp::tools::bulk_search::BulkSearchInput { queries: vec![] };
    let result = kebab_mcp::tools::bulk_search::handle(handler.state(), input);
    let v = extract_json(&result);
    assert_eq!(v["schema_version"], "bulk_search_response.v1");
    assert_eq!(v["results"].as_array().unwrap().len(), 0);
    assert_eq!(v["summary"]["total"], 0);
}

#[tokio::test]
async fn bulk_search_invalid_item_field_continues_with_per_item_error() {
    let (_dir, handler) = setup();
    let input = kebab_mcp::tools::bulk_search::BulkSearchInput {
        queries: vec![
            json!({"query": "kebab", "mode": "lexical"}),
            json!({"query": "bread", "mode": "bogus"}),  // invalid mode
        ],
    };
    let result = kebab_mcp::tools::bulk_search::handle(handler.state(), input);
    let v = extract_json(&result);
    let results = v["results"].as_array().unwrap();
    assert_eq!(results.len(), 2);
    assert!(results[0]["error"].is_null());
    assert!(results[1]["error"].is_object());
    assert_eq!(results[1]["error"]["code"], "invalid_input");
    assert_eq!(v["summary"]["succeeded"], 1);
    assert_eq!(v["summary"]["failed"], 1);
}

#[tokio::test]
async fn bulk_search_over_cap_returns_tool_error() {
    let (_dir, handler) = setup();
    let queries: Vec<serde_json::Value> = (0..101)
        .map(|_| json!({"query": "x", "mode": "lexical"}))
        .collect();
    let input = kebab_mcp::tools::bulk_search::BulkSearchInput { queries };
    let result = kebab_mcp::tools::bulk_search::handle(handler.state(), input);
    assert!(result.is_error.unwrap_or(false), "expected isError=true");
    let content = result.content.first().expect("error content");
    let text = match &content.raw {
        RawContent::Text(t) => &t.text,
        other => panic!("expected Text content, got {other:?}"),
    };
    assert!(text.contains("max 100"), "expected 'max 100' in error: {text}");
}
  • Step 3: Run tests
cargo test -p kebab-mcp --test tools_call_bulk_search

Expected: 4 tests pass.

  • Step 4: Commit
git add crates/kebab-mcp/tests/tools_call_bulk_search.rs
git commit -m "test(mcp): integration tests for bulk_search tool (fb-42)"

Files:

  • Modify: crates/kebab-app/src/schema.rs

  • Step 1: Add bulk_search field to Capabilities struct + snapshot value

In crates/kebab-app/src/schema.rs, find pub struct Capabilities { ... } (~line 24). Append field:

    pub bulk_search: bool,

Find fn capabilities_snapshot() -> Capabilities { ... } (~line 114). Add field initializer inside:

        bulk_search: true,
  • Step 2: Update existing schema integration test (if any asserts capability count)
grep -rn "capabilities\.\|Capabilities {" crates/kebab-app/ crates/kebab-cli/tests/ | head -10

If any test asserts capabilities.json_mode etc., extend with bulk_search assertion. If a test deserializes schema JSON and checks capability count, bump.

If crates/kebab-cli/tests/cli_schema.rs exists with capability assertions, update.

  • Step 3: Run tests
cargo test -p kebab-app -p kebab-cli
cargo clippy -p kebab-app --all-targets -- -D warnings

Expected: all pass; clippy clean.

  • Step 4: Commit
git add crates/kebab-app/src/schema.rs crates/kebab-cli/tests/
git commit -m "feat(schema): bulk_search capability flag (fb-42)"

Task 8: Wire schema docs + README + SMOKE + design + SKILL + INDEX + status flip

Files:

  • Create: docs/wire-schema/v1/bulk_search_item.schema.json

  • Create: docs/wire-schema/v1/bulk_search_response.schema.json

  • Modify: crates/kebab-app/src/schema.rs — add new schemas to WIRE_SCHEMAS const list.

  • Modify: README.md

  • Modify: docs/SMOKE.md

  • Modify: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md

  • Modify: integrations/claude-code/kebab/SKILL.md

  • Modify: tasks/p9/p9-fb-42-bulk-multi-query-rerank.md

  • Modify: tasks/INDEX.md

  • Step 1: Create docs/wire-schema/v1/bulk_search_item.schema.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://kb.local/wire/v1/bulk_search_item.schema.json",
  "title": "BulkSearchItem v1",
  "description": "p9-fb-42: per-query result inside a bulk_search response. `response` XOR `error` — exactly one is non-null. `query` is the input echo so consumers can correlate without index tracking.",
  "type": "object",
  "required": ["schema_version", "query", "response", "error"],
  "properties": {
    "schema_version": { "const": "bulk_search_item.v1" },
    "query":   { "type": "object", "description": "Input echo (verbatim JSON object)." },
    "response":{
      "type": ["object", "null"],
      "description": "search_response.v1 payload on success; null when error is non-null."
    },
    "error":   {
      "type": ["object", "null"],
      "description": "error.v1 payload when this query failed; null on success."
    }
  }
}
  • Step 2: Create docs/wire-schema/v1/bulk_search_response.schema.json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://kb.local/wire/v1/bulk_search_response.schema.json",
  "title": "BulkSearchResponse v1",
  "description": "p9-fb-42: MCP envelope for bulk_search. CLI emits raw `bulk_search_item.v1` ndjson without this envelope (summary on stderr).",
  "type": "object",
  "required": ["schema_version", "results", "summary"],
  "properties": {
    "schema_version": { "const": "bulk_search_response.v1" },
    "results": {
      "type": "array",
      "items": { "type": "object", "description": "bulk_search_item.v1" }
    },
    "summary": {
      "type": "object",
      "required": ["total", "succeeded", "failed"],
      "properties": {
        "total":     { "type": "integer", "minimum": 0 },
        "succeeded": { "type": "integer", "minimum": 0 },
        "failed":    { "type": "integer", "minimum": 0 }
      }
    }
  }
}
  • Step 3: Register new schemas in WIRE_SCHEMAS const

In crates/kebab-app/src/schema.rs, find const WIRE_SCHEMAS: &[&str] = &[ ... ] (~line 65). Add:

    "bulk_search_item.v1",
    "bulk_search_response.v1",
  • Step 4: Update README.md

Find the search command row in the command table or flag list. Add a --bulk mention next to other search flags. If the README has a "검색" section, add a paragraph:

- `--bulk` (fb-42) — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson + stderr summary. Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip.

Also add the kebab__bulk_search MCP tool to the "MCP 도구" list if such a list exists.

  • Step 5: Add SMOKE walkthrough

Append a new section to docs/SMOKE.md after the fb-37/fb-38 walkthroughs:

### Bulk multi-query (fb-42)

Stdin ndjson으로 N query 한 번에:

\`\`\`bash
printf '{"query":"rust","mode":"lexical"}\n{"query":"async","mode":"lexical"}\n' \
  | kebab --config /tmp/kebab-smoke/config.toml search --bulk --json
\`\`\`

stdout: per-query ndjson (`bulk_search_item.v1`). stderr: `bulk_summary: total=2 succeeded=2 failed=0`.

MCP tool 동등:

\`\`\`json
{"name":"kebab__bulk_search","arguments":{"queries":[{"query":"rust"},{"query":"async"}]}}
\`\`\`
  • Step 6: Update design §4 search

Find §4 search in docs/superpowers/specs/2026-04-27-kebab-final-form-design.md. Append a "Bulk multi-query (fb-42)" subsection with the input shape + output shape (per-query item + envelope) + cap 100 + per-query error policy.

grep -n "^## §4\|^### §4\|search.*bulk\|^## 4 검색" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md | head -5

Insert after the existing search content. Mirror the fb-37 trace section's brevity.

  • Step 7: Update SKILL.md

Find the mcp__kebab__search section in integrations/claude-code/kebab/SKILL.md. After it, add a sibling mcp__kebab__bulk_search section:

### `mcp__kebab__bulk_search`

N개 query 한 번에 — agent loop 효율 개선. 각 query 는 `mcp__kebab__search` 와 동일 input shape (query 필수, 나머지 optional). Cap 100.

Input:
\`\`\`json
{"queries": [{"query": "..."}, {"query": "...", "mode": "lexical"}, ...]}
\`\`\`

Output: `bulk_search_response.v1` envelope — `results: [bulk_search_item.v1]` (각 item = `{query, response | null, error | null}`) + `summary: {total, succeeded, failed}`. Per-query 실패는 item 의 error 에 격리, 다른 query 계속 진행.
  • Step 8: Flip task spec status

Edit tasks/p9/p9-fb-42-bulk-multi-query-rerank.md. Change frontmatter status: openstatus: completed. Replace the skeleton banner with:

> ✅ **Bulk multi-query 부분 구현 완료.** 본 spec 의 rerank hint lever 는 별도 task 로 분리 (fb-39 cross-encoder 설계 후).
>
> - Design: [`docs/superpowers/specs/2026-05-10-p9-fb-42-bulk-multi-query-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-42-bulk-multi-query-design.md)
> - Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-42-bulk-multi-query.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-42-bulk-multi-query.md)
  • Step 9: Flip INDEX row

In tasks/INDEX.md, find the fb-42 row. Mirror the format ✅ 머지 (2026-05-10) — bulk only, rerank hint deferred (preserve the deferral note since fb-42's stub had two levers).

  • Step 10: Run full workspace tests + clippy
cargo test --workspace --no-fail-fast -j 1 2>&1 | tail -10
cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5

-j 1 REQUIRED for workspace test.

Expected: all green.

  • Step 11: Commit
git add docs/ README.md crates/kebab-app/src/schema.rs tasks/p9/p9-fb-42-bulk-multi-query-rerank.md tasks/INDEX.md integrations/claude-code/kebab/SKILL.md
git commit -m "docs(fb-42): wire schema + README + SMOKE + design + SKILL + INDEX"

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:
    • printf '{"query":"a"}\n{"query":"b"}\n' | kebab search --bulk --json returns 2 ndjson lines + stderr summary
    • kebab schema --json | jq .capabilities.bulk_search returns true
    • kebab schema --json | jq .wire.schemas includes "bulk_search_item.v1" and "bulk_search_response.v1"
  • README, SMOKE, design §4, SKILL, INDEX, spec status all updated