Files
kebab/docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md
th-kim0823 dbb7b54d5d plan(fb-34): output budget controls implementation plan
11 tasks: SearchOpts (kebab-core), cursor module + base64 dep
(kebab-app), error_wire stale_cursor convention, App::search_with_opts
+ SearchResponse + budget loop, wire schema search_response.v1, CLI
flags + plain truncated hint, CLI integration tests, MCP wrapper +
inputs, workspace+clippy gate, docs (README/SMOKE/INDEX/HOTFIXES/
skill), smoke+PR.

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

51 KiB

p9-fb-34 — Output Budget Controls 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 --max-tokens / --snippet-chars / --cursor flags to kebab search so agents can cap result size and paginate. Wire output gains a top-level search_response.v1 wrapper around the existing search_hit.v1[] array, with next_cursor and truncated metadata.

Architecture: Domain SearchOpts enters App::search_with_opts(query, opts) -> SearchResponse; existing App::search(query) -> Vec<SearchHit> becomes a thin wrapper. Token estimation uses chars/4 (no new tokenizer dep). Truncate priority: snippet shorten → k pop → minimum 1 hit. Cursor is opaque base64 of {offset, corpus_revision} JSON; mismatch returns error.v1.code = stale_cursor. CLI plain output unchanged + truncated stderr hint; --json output is the new wrapper.

Tech Stack: Rust 2024, base64 (workspace dep — add to root if missing), serde, JSON Schema (search_response.v1).

Spec: docs/superpowers/specs/2026-05-09-p9-fb-34-output-budget-controls-design.md


File Structure

File Responsibility Action
crates/kebab-core/src/search.rs New pub struct SearchOpts { max_tokens, snippet_chars, cursor } with Default impl modify
crates/kebab-core/src/lib.rs Re-export SearchOpts modify
crates/kebab-app/src/cursor.rs New module — encode_cursor(offset, revision) -> String, decode_cursor(s, expected) -> Result<usize, ErrorV1> create
crates/kebab-app/src/app.rs New pub struct SearchResponse, App::search_with_opts(...), budget loop, retain App::search thin wrapper modify
crates/kebab-app/src/lib.rs Re-export SearchResponse, SearchOpts, cursor module if needed modify
crates/kebab-app/src/error_wire.rs Add stale_cursor classify branch modify
crates/kebab-app/Cargo.toml Add base64 dep (or workspace-managed) modify
Cargo.toml (workspace root) Add base64 = "0.22" to [workspace.dependencies] if not already managed modify (conditional)
crates/kebab-cli/src/main.rs Cmd::Search new flags + dispatch to search_with_opts + plain truncated hint modify
crates/kebab-cli/src/wire.rs New wire_search_response(&SearchResponse) -> Value helper modify
crates/kebab-mcp/src/tools/search.rs Extend SearchInput + emit search_response.v1 modify
docs/wire-schema/v1/search_response.schema.json NEW wrapper schema create
crates/kebab-app/tests/cursor.rs Unit: encode/decode round-trip + StaleCursor create
crates/kebab-app/tests/search_budget_integration.rs Integration: budget None passthrough + snippet shorten + k pop + 1-hit minimum + snippet_chars override + cursor pagination + corpus_revision bump → StaleCursor create
crates/kebab-cli/tests/wire_search_response.rs Integration: --json shape + --max-tokens truncation + --cursor next page + plain truncated stderr hint create
crates/kebab-mcp/tests/tools_call_search.rs Augment existing test (or sibling) — verify search_response.v1 returned modify
README.md kebab search row update + --max-tokens / --cursor mention modify
docs/SMOKE.md Pagination walkthrough paragraph modify
tasks/p9/p9-fb-34-output-budget-controls.md Status flip + design/plan links modify
tasks/INDEX.md fb-34 row → modify
tasks/HOTFIXES.md New entry — 2026-05-09 — p9-fb-34: search wire wrapped in search_response.v1 modify
integrations/claude-code/kebab/SKILL.md Recipe update — response.hits[] instead of bare array; cursor example modify

Pre-flight

  • Step 0.1: Branch off main
git checkout main
git pull
git checkout -b feat/fb-34-output-budget-controls
  • Step 0.2: Confirm spec branch reachable
git log --oneline spec/fb-34-output-budget-controls -1

Expected: a80f65c spec(fb-34): output budget controls — design. If spec PR has not yet merged into main, git merge spec/fb-34-output-budget-controls so the spec doc lands on this branch.


Task 1: Domain — SearchOpts in kebab-core

Files:

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

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

  • Step 1.1: Write the failing test

Append to crates/kebab-core/src/search.rs #[cfg(test)] mod tests block (one already exists from fb-32):

#[test]
fn search_opts_default_is_all_none() {
    let opts = SearchOpts::default();
    assert!(opts.max_tokens.is_none());
    assert!(opts.snippet_chars.is_none());
    assert!(opts.cursor.is_none());
}
  • Step 1.2: Run test — verify failure
cargo test -p kebab-core search_opts_default_is_all_none

Expected: FAIL — cannot find type SearchOpts in scope.

  • Step 1.3: Define SearchOpts

Append to crates/kebab-core/src/search.rs (after the existing DocSummary struct, before any #[cfg(test)]):

/// 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<usize>,
    /// Per-hit snippet character cap. None = use config default.
    pub snippet_chars: Option<usize>,
    /// Opaque base64 cursor from a previous response. None = first page.
    pub cursor: Option<String>,
}
  • Step 1.4: Re-export from crates/kebab-core/src/lib.rs

Find the existing pub use search::{...} line:

grep -n "pub use search" crates/kebab-core/src/lib.rs

Add SearchOpts to the brace list. If the existing line is e.g. pub use search::{SearchHit, SearchQuery, SearchFilters, SearchMode, RetrievalDetail, DocFilter, DocSummary};, append SearchOpts.

  • Step 1.5: Run tests — verify pass
cargo test -p kebab-core search_opts_default_is_all_none
cargo test -p kebab-core

Expected: PASS.

  • Step 1.6: Commit
git add crates/kebab-core/src/search.rs crates/kebab-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): SearchOpts domain type for budget controls (fb-34)

3 optional knobs (max_tokens, snippet_chars, cursor); Default = all
None = no enforcement (backwards-compat existing search behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: Cursor encode/decode helper

Files:

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

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

  • Modify: crates/kebab-app/Cargo.toml

  • Possibly modify: Cargo.toml (workspace root) — add base64 to [workspace.dependencies] if absent

  • Step 2.1: Add base64 to kebab-app deps

Check workspace root Cargo.toml:

grep -n "^base64" Cargo.toml

If absent, add to [workspace.dependencies]:

base64 = "0.22"

Then add to crates/kebab-app/Cargo.toml [dependencies]:

base64 = { workspace = true }

If base64 is already directly in another crate (e.g. kebab-parse-image), promote it to workspace dep first then update both.

  • Step 2.2: Write the failing test

Create crates/kebab-app/tests/cursor.rs:

//! p9-fb-34: cursor encode/decode round-trip + corpus_revision mismatch.

use kebab_app::cursor;

#[test]
fn cursor_roundtrip_preserves_offset() {
    let encoded = cursor::encode(5, "rev-abc");
    let offset = cursor::decode(&encoded, "rev-abc").unwrap();
    assert_eq!(offset, 5);
}

#[test]
fn cursor_decode_rejects_mismatched_revision() {
    let encoded = cursor::encode(7, "rev-old");
    let err = cursor::decode(&encoded, "rev-new").unwrap_err();
    assert_eq!(err.code, "stale_cursor");
    assert!(err.message.contains("rev-old") || err.message.contains("rev-new"));
}

#[test]
fn cursor_decode_rejects_garbage_input() {
    let err = cursor::decode("not-base64!!!", "any").unwrap_err();
    assert_eq!(err.code, "stale_cursor");
}
  • Step 2.3: Run test — verify failure
cargo test -p kebab-app --test cursor

Expected: FAIL — cannot find module cursor in kebab_app.

  • Step 2.4: Implement cursor module

Create crates/kebab-app/src/cursor.rs:

//! p9-fb-34 opaque pagination cursor.
//!
//! Format: base64(JSON({offset: usize, corpus_revision: string})).
//! Opaque to callers — they MUST NOT decode the contents themselves;
//! the schema is internal and may change without notice.

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};

use crate::error_wire::ErrorV1;

#[derive(Serialize, Deserialize)]
struct Payload {
    offset: usize,
    corpus_revision: String,
}

/// Encode `(offset, corpus_revision)` as an opaque base64 string.
pub fn encode(offset: usize, corpus_revision: &str) -> String {
    let payload = Payload {
        offset,
        corpus_revision: corpus_revision.to_string(),
    };
    let json = serde_json::to_vec(&payload).expect("Payload serializes");
    URL_SAFE_NO_PAD.encode(&json)
}

/// Decode an opaque cursor against the expected `corpus_revision`.
/// Mismatch or malformed input returns an `ErrorV1` with
/// `code = "stale_cursor"`.
pub fn decode(s: &str, expected_revision: &str) -> Result<usize, ErrorV1> {
    let bytes = URL_SAFE_NO_PAD.decode(s.as_bytes()).map_err(|_| stale(
        "<malformed>",
        expected_revision,
    ))?;
    let payload: Payload = serde_json::from_slice(&bytes).map_err(|_| stale(
        "<malformed>",
        expected_revision,
    ))?;
    if payload.corpus_revision != expected_revision {
        return Err(stale(&payload.corpus_revision, expected_revision));
    }
    Ok(payload.offset)
}

fn stale(found: &str, expected: &str) -> ErrorV1 {
    ErrorV1 {
        schema_version: "error.v1".to_string(),
        code: "stale_cursor".to_string(),
        message: format!(
            "cursor was issued against corpus_revision '{found}'; current revision is \
             '{expected}'. Re-issue search to obtain a fresh cursor."
        ),
        cause: None,
    }
}

If ErrorV1 field names differ (verify via grep -A 10 "pub struct ErrorV1" crates/kebab-app/src/error_wire.rs), adapt the struct literal accordingly.

  • Step 2.5: Wire the module into the crate

Edit crates/kebab-app/src/lib.rs. Find the mod declarations near the top and add:

pub mod cursor;

(Use pub mod so cursor::encode / cursor::decode are reachable from the integration test.)

  • Step 2.6: Run tests — verify pass
cargo test -p kebab-app --test cursor

Expected: 3 PASS.

  • Step 2.7: Commit
git add crates/kebab-app/src/cursor.rs crates/kebab-app/src/lib.rs crates/kebab-app/Cargo.toml Cargo.toml Cargo.lock crates/kebab-app/tests/cursor.rs
git commit -m "$(cat <<'EOF'
feat(app): cursor encode/decode for paginated search (fb-34)

Opaque base64(JSON{offset, corpus_revision}). Mismatch or
malformed input returns ErrorV1 with code = stale_cursor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: error_wirestale_cursor classification

Files:

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

  • Step 3.1: Write the failing test

Append to crates/kebab-app/src/error_wire.rs #[cfg(test)] mod tests:

#[test]
fn stale_cursor_classifies_correctly() {
    use anyhow::anyhow;
    let err: anyhow::Error = anyhow!("stale_cursor: rev mismatch");
    let v1 = classify(&err, false);
    // Without explicit downcast support, the generic anyhow path
    // will fall through to "unknown" — the actual stale_cursor
    // ErrorV1 is constructed directly by `cursor::decode`, not via
    // the classify path. This test pins that behavior so future
    // refactors of classify don't accidentally clobber the code.
    assert_ne!(v1.code, "stale_cursor", "classify is not the source for stale_cursor");
}

(If a richer classification is desired, add a downcast branch — but per the spec, cursor::decode returns ErrorV1 directly so the classify path doesn't need to handle it. The test exists to lock that invariant.)

  • Step 3.2: Run test — verify it passes immediately
cargo test -p kebab-app --lib stale_cursor_classifies_correctly

Expected: PASS (no implementation needed — classify already returns "unknown" for unrecognized errors).

  • Step 3.3: Document the convention

Add a comment near the top of crates/kebab-app/src/error_wire.rs:

// p9-fb-34: `stale_cursor` is constructed directly by `cursor::decode`
// instead of routed through `classify`. Keep that contract — adding a
// classify branch would create two sources of truth for the same code.
  • Step 3.4: Commit
git add crates/kebab-app/src/error_wire.rs
git commit -m "$(cat <<'EOF'
docs(error_wire): note stale_cursor convention (fb-34)

stale_cursor is built by cursor::decode, not classify. Test
locks the invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: App::search_with_opts + SearchResponse

Files:

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

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

  • Step 4.1: Write the failing integration test (passthrough)

Create crates/kebab-app/tests/search_budget_integration.rs:

//! p9-fb-34: App::search_with_opts integration tests.

mod common;

use kebab_app::SearchResponse;
use kebab_core::{SearchFilters, SearchMode, SearchOpts, SearchQuery};

fn lex(text: &str, k: usize) -> SearchQuery {
    SearchQuery {
        text: text.to_string(),
        mode: SearchMode::Lexical,
        k,
        filters: SearchFilters::default(),
    }
}

#[test]
fn search_with_opts_no_budget_matches_search() {
    let env = common::TestEnv::new();
    common::ingest_md(&env, "a.md", "# T\n\napples are red\n");
    let app = env.app();

    let baseline = app.search(lex("apples", 5)).unwrap();
    let resp: SearchResponse = app
        .search_with_opts(lex("apples", 5), SearchOpts::default())
        .unwrap();

    assert_eq!(resp.hits.len(), baseline.len());
    assert!(!resp.truncated);
    assert!(resp.next_cursor.is_none(), "k=5 against 1 doc → no next page");
}
  • Step 4.2: Run — verify failure
cargo test -p kebab-app --test search_budget_integration search_with_opts_no_budget_matches_search

Expected: FAIL — cannot find type SearchResponse / method search_with_opts.

  • Step 4.3: Define SearchResponse + skeleton search_with_opts

In crates/kebab-app/src/app.rs, after the existing pub use kebab_core::{...}; imports and before the App struct (or wherever public types belong), add:

/// p9-fb-34: top-level wrapper around a paginated, budget-limited
/// search result. Mirrors the wire `search_response.v1` shape.
#[derive(Clone, Debug)]
pub struct SearchResponse {
    pub hits: Vec<SearchHit>,
    pub next_cursor: Option<String>,
    pub truncated: bool,
}

Then in impl App, add:

/// p9-fb-34: budget-aware search facade. Returns hits trimmed to
/// `opts.max_tokens` (chars/4 approximation) plus pagination
/// metadata. `App::search` is now a thin wrapper that drops the
/// metadata for backwards compat.
pub fn search_with_opts(
    &self,
    query: SearchQuery,
    opts: SearchOpts,
) -> Result<SearchResponse> {
    use crate::cursor;

    let corpus_revision = self.sqlite.corpus_revision().to_string();
    let offset = match opts.cursor.as_ref() {
        Some(c) => cursor::decode(c, &corpus_revision)
            .map_err(|e| anyhow::anyhow!("stale_cursor: {}", e.message))?,
        None => 0,
    };

    let snippet_chars = opts
        .snippet_chars
        .unwrap_or(self.config.search.snippet_chars);

    // Fetch enough to satisfy offset + requested page.
    let k_effective = query.k.max(self.config.search.default_k);
    let fetch_k = offset.saturating_add(k_effective);
    let fetch_query = SearchQuery {
        k: fetch_k,
        ..query.clone()
    };
    let mut all_hits = self.search(fetch_query)?;

    // Skip offset.
    let drop_n = offset.min(all_hits.len());
    all_hits.drain(..drop_n);
    let mut hits: Vec<SearchHit> = all_hits.into_iter().take(k_effective).collect();

    // Apply snippet_chars override (production search already used
    // config snippet_chars; this re-trims if the override is shorter).
    if opts.snippet_chars.is_some() {
        for h in hits.iter_mut() {
            if h.snippet.chars().count() > snippet_chars {
                h.snippet = trim_to_chars(&h.snippet, snippet_chars);
            }
        }
    }

    // Budget loop.
    let mut truncated = false;
    if let Some(max_tokens) = opts.max_tokens {
        let max_chars = max_tokens.saturating_mul(4);
        // Step 1: shorten snippets progressively to a 60-char floor.
        const SNIPPET_FLOOR: usize = 60;
        let mut current_snippet_cap = snippet_chars;
        while estimate_chars(&hits) > max_chars && current_snippet_cap > SNIPPET_FLOOR {
            current_snippet_cap = (current_snippet_cap / 2).max(SNIPPET_FLOOR);
            for h in hits.iter_mut() {
                if h.snippet.chars().count() > current_snippet_cap {
                    h.snippet = trim_to_chars(&h.snippet, current_snippet_cap);
                    truncated = true;
                }
            }
        }
        // Step 2: pop hits from the end until we fit, but always keep ≥ 1.
        while estimate_chars(&hits) > max_chars && hits.len() > 1 {
            hits.pop();
            truncated = true;
        }
    }

    // Compute next_cursor: did we have more in the original fetch?
    let returned = hits.len();
    let next_cursor = if returned == k_effective && offset.saturating_add(returned) > 0 {
        // Speculative: the retriever returned exactly k_effective hits
        // after offset, so there *might* be more. Encoding the cursor
        // is cheap; the next call falls through to an empty page if
        // nothing remains.
        Some(cursor::encode(offset + returned, &corpus_revision))
    } else if truncated && returned > 0 {
        // Budget-truncated mid-page; let the caller resume from where
        // we stopped.
        Some(cursor::encode(offset + returned, &corpus_revision))
    } else {
        None
    };

    Ok(SearchResponse {
        hits,
        next_cursor,
        truncated,
    })
}

Add the helpers near the bottom of app.rs (or in cursor.rs if cleaner — keep them adjacent to where they're called):

/// p9-fb-34: trim to N chars (Unicode-safe).
fn trim_to_chars(s: &str, n: usize) -> String {
    if s.chars().count() <= n {
        return s.to_string();
    }
    let mut out = String::with_capacity(n * 4);
    for (i, c) in s.chars().enumerate() {
        if i >= n {
            break;
        }
        out.push(c);
    }
    out
}

/// p9-fb-34: estimate wire JSON char cost of the hit list. The wire
/// shape adds object/array boilerplate (~50 chars per hit), so we
/// approximate by serializing each hit and summing chars. Cheap
/// enough to call inside the budget loop on small k.
fn estimate_chars(hits: &[SearchHit]) -> usize {
    hits.iter()
        .map(|h| serde_json::to_string(h).map(|s| s.len()).unwrap_or(0))
        .sum()
}
  • Step 4.4: Run passthrough test — verify pass
cargo test -p kebab-app --test search_budget_integration search_with_opts_no_budget_matches_search

Expected: PASS.

  • Step 4.5: Re-export SearchResponse

Edit crates/kebab-app/src/lib.rs:

pub use app::{App, SearchResponse};

(The existing pub use app::App; line gains SearchResponse.)

  • Step 4.6: Add budget-shorten test

Append to crates/kebab-app/tests/search_budget_integration.rs:

#[test]
fn budget_truncates_snippets_when_below_threshold() {
    let env = common::TestEnv::new();
    // Long body so snippet has room to shrink.
    let body: String = "rust ownership is a memory model. ".repeat(10);
    common::ingest_md(&env, "a.md", &format!("# T\n\n{body}\n"));
    let app = env.app();

    let unrestricted = app.search(lex("rust", 5)).unwrap();
    let unrestricted_chars: usize = unrestricted.iter().map(|h| h.snippet.chars().count()).sum();

    let resp = app
        .search_with_opts(
            lex("rust", 5),
            SearchOpts {
                max_tokens: Some(50), // ~200 chars total cap, well under unrestricted
                snippet_chars: None,
                cursor: None,
            },
        )
        .unwrap();
    let limited_chars: usize = resp.hits.iter().map(|h| h.snippet.chars().count()).sum();

    assert!(resp.truncated, "small budget must trip truncation");
    assert!(limited_chars < unrestricted_chars, "snippet should shrink");
    assert!(!resp.hits.is_empty(), "always retain ≥1 hit");
}
  • Step 4.7: Run + verify
cargo test -p kebab-app --test search_budget_integration

Expected: 2 PASS.

  • Step 4.8: Add cursor-pagination + stale-cursor tests

Append to crates/kebab-app/tests/search_budget_integration.rs:

#[test]
fn cursor_paginates_to_next_page() {
    let env = common::TestEnv::new();
    // Seed N docs so k=2 returns multiple pages.
    for i in 0..6 {
        common::ingest_md(&env, &format!("d{i}.md"), &format!("# T{i}\n\nrust topic {i}\n"));
    }
    let app = env.app();

    let page1 = app
        .search_with_opts(lex("rust", 2), SearchOpts::default())
        .unwrap();
    assert_eq!(page1.hits.len(), 2);
    let cursor = page1.next_cursor.expect("more hits available");

    let page2 = app
        .search_with_opts(
            lex("rust", 2),
            SearchOpts {
                max_tokens: None,
                snippet_chars: None,
                cursor: Some(cursor),
            },
        )
        .unwrap();
    assert_eq!(page2.hits.len(), 2);
    // Second page must contain different hits than first.
    let p1_ids: std::collections::HashSet<_> = page1.hits.iter().map(|h| h.chunk_id.0.clone()).collect();
    let p2_ids: std::collections::HashSet<_> = page2.hits.iter().map(|h| h.chunk_id.0.clone()).collect();
    assert!(p1_ids.is_disjoint(&p2_ids), "page 2 must not repeat page 1 hits");
}

#[test]
fn cursor_rejected_after_corpus_revision_bump() {
    let env = common::TestEnv::new();
    common::ingest_md(&env, "a.md", "# T\n\napples\n");
    let app = env.app();

    let page1 = app
        .search_with_opts(lex("apples", 1), SearchOpts::default())
        .unwrap();
    let cursor = page1.next_cursor;

    if let Some(c) = cursor {
        // Force a corpus_revision bump.
        common::ingest_md(&env, "b.md", "# B\n\nbananas\n");
        let app2 = env.app(); // re-open to pick up new revision

        let result = app2.search_with_opts(
            lex("apples", 1),
            SearchOpts {
                max_tokens: None,
                snippet_chars: None,
                cursor: Some(c),
            },
        );
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("stale_cursor"),
            "must surface stale_cursor: {err}"
        );
    }
    // If page1 had no next_cursor (k=1 and only 1 doc), this branch
    // is unreachable but the test still passes — exercises the
    // happy-no-cursor path.
}
  • Step 4.9: Run + verify
cargo test -p kebab-app --test search_budget_integration

Expected: 4 PASS.

If common::TestEnv::app() returns a freshly-built App each call, the corpus_revision bump test works. If it caches, you may need a env.reopen_app() helper — extend tests/common/mod.rs.

  • Step 4.10: Verify existing App::search callers still work
cargo test -p kebab-app
cargo build --workspace

Expected: green. App::search signature unchanged so TUI / kebab-rag callers compile.

  • Step 4.11: Commit
git add crates/kebab-app/src/app.rs crates/kebab-app/src/lib.rs crates/kebab-app/tests/search_budget_integration.rs
git commit -m "$(cat <<'EOF'
feat(app): App::search_with_opts + SearchResponse (fb-34)

Budget loop: snippet shorten → k pop → ≥1 hit floor. Cursor
encode/decode threads corpus_revision; mismatch surfaces as
stale_cursor anyhow error. App::search retained as thin wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: Wire schema — search_response.v1

Files:

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

  • Step 5.1: Write the schema

Create docs/wire-schema/v1/search_response.schema.json:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://kb.local/wire/v1/search_response.schema.json",
  "title": "SearchResponse v1",
  "description": "Top-level wrapper for `kebab search --json` output. Replaces the bare `search_hit.v1[]` array — wraps it with pagination + truncation metadata. Token counts are approximate (chars/4 estimate, no tokenizer dep).",
  "type": "object",
  "required": ["schema_version", "hits", "next_cursor", "truncated"],
  "properties": {
    "schema_version": { "const": "search_response.v1" },
    "hits":           { "type": "array", "description": "search_hit.v1[]" },
    "next_cursor":    { "type": ["string", "null"], "description": "Opaque base64 cursor for next page; null when no more hits." },
    "truncated":      { "type": "boolean", "description": "True when budget forced snippet shortening or k reduction. Caller can request next page via next_cursor or pass higher k." }
  }
}
  • Step 5.2: Validate
python3 -c "import json; json.load(open('docs/wire-schema/v1/search_response.schema.json'))"

Expected: silent success.

  • Step 5.3: Commit
git add docs/wire-schema/v1/search_response.schema.json
git commit -m "$(cat <<'EOF'
feat(wire): search_response.v1 schema (fb-34)

Wrapper around search_hit.v1[] with next_cursor + truncated.
Wire breaking — agent that parses bare array must adapt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: CLI --max-tokens / --snippet-chars / --cursor

Files:

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

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

  • Step 6.1: Add wire_search_response helper

Locate crates/kebab-cli/src/wire.rs. After wire_search_hits, append:

/// p9-fb-34: tag a `SearchResponse` as `search_response.v1`. Wraps
/// the existing `search_hit.v1[]` array with pagination + truncation
/// metadata.
pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value {
    let v = serde_json::json!({
        "hits": r.hits.iter().map(wire_search_hit).collect::<Vec<_>>(),
        "next_cursor": r.next_cursor,
        "truncated": r.truncated,
    });
    tag_object(v, "search_response.v1")
}
  • Step 6.2: Add clap flags + dispatch

Locate the Cmd::Search enum variant in crates/kebab-cli/src/main.rs:

grep -n "Cmd::Search" crates/kebab-cli/src/main.rs | head -3

Add three new fields to the variant definition (the enum Cmd { ... Search { query, k, mode, explain, no_cache, ... } } block):

/// p9-fb-34: cap result wire JSON size at approximately N tokens
/// (chars/4 estimate). When set, smaller snippets and fewer hits
/// may be returned; check `truncated` in the JSON wire.
#[arg(long)]
max_tokens: Option<usize>,
/// p9-fb-34: per-hit snippet character cap, overrides
/// `config.search.snippet_chars` for this call only.
#[arg(long)]
snippet_chars: Option<usize>,
/// p9-fb-34: opaque cursor from a previous response's
/// `next_cursor` to fetch the next page. Mismatched
/// `corpus_revision` returns `error.v1.code = stale_cursor`.
#[arg(long)]
cursor: Option<String>,

In the match arm, replace the existing dispatch (around Cmd::Search { query, k, mode, explain: _, no_cache } =>):

Cmd::Search {
    query,
    k,
    mode,
    explain: _,
    no_cache,
    max_tokens,
    snippet_chars,
    cursor,
} => {
    let cfg = kebab_config::Config::load(cli.config.as_deref())?;
    let q = kebab_core::SearchQuery {
        text: query.clone(),
        mode: (*mode).into(),
        k: *k,
        filters: kebab_core::SearchFilters::default(),
    };
    let opts = kebab_core::SearchOpts {
        max_tokens: *max_tokens,
        snippet_chars: *snippet_chars,
        cursor: cursor.clone(),
    };
    // p9-fb-34: budget-aware path. --no-cache still bypasses the
    // App-level LRU; wire wrapper applies regardless.
    let app = kebab_app::App::open_with_config(cfg)?;
    let resp = if *no_cache {
        // search_uncached_with_opts not exposed; degrade by
        // clearing cache then calling search_with_opts.
        app.clear_search_cache();
        app.search_with_opts(q, opts)?
    } else {
        app.search_with_opts(q, opts)?
    };

    if cli.json {
        println!("{}", serde_json::to_string(&wire::wire_search_response(&resp))?);
    } else {
        // Plain output unchanged — list hits with [stale] tag
        // (fb-32) per existing convention. Truncation hint goes
        // to stderr so it doesn't pollute stdout.
        use std::io::IsTerminal;
        let color = std::io::stdout().is_terminal();
        for h in &resp.hits {
            let heading = if h.heading_path.is_empty() {
                String::new()
            } else {
                format!("  >  {}", h.heading_path.join(" / "))
            };
            let stale_tag = if h.stale {
                if color { "\x1b[33m[stale]\x1b[0m " } else { "[stale] " }
            } else {
                ""
            };
            println!(
                "{:>2}. {:.4}  {}{}{}",
                h.rank, h.retrieval.fusion_score, stale_tag, h.doc_path.0, heading,
            );
        }
        if resp.truncated {
            let next = resp.next_cursor.as_deref().unwrap_or("(none)");
            eprintln!("[truncated; use --cursor {next} for the next page]");
        }
    }
    Ok(())
}

If the existing path uses kebab_app::search_with_config / search_uncached_with_config (free functions rather than App::open_with_config), grep for the actual idiom:

grep -n "kebab_app::search\|App::open_with_config" crates/kebab-cli/src/main.rs | head -5

Adapt the dispatch to match — the goal is App::search_with_opts(query, opts). If a *_with_opts_with_config free function is preferred, add it to crates/kebab-app/src/lib.rs mirroring the existing search_with_config shape:

pub fn search_with_opts_with_config(
    config: kebab_config::Config,
    query: SearchQuery,
    opts: SearchOpts,
) -> anyhow::Result<SearchResponse> {
    App::open_with_config(config)?.search_with_opts(query, opts)
}
  • Step 6.3: Build the CLI
cargo build -p kebab-cli

Expected: clean.

  • Step 6.4: Verify --help shows the new flags
cargo run -q -p kebab-cli -- search --help 2>&1 | grep -E "max-tokens|snippet-chars|cursor"

Expected: 3 lines, one per flag.

  • Step 6.5: Run kebab-cli existing tests
cargo test -p kebab-cli

Expected: existing tests pass. If a wire test asserts the OLD bare search_hit.v1[] shape, it will fail — update those tests now to expect search_response.v1. Search:

grep -rn "search_hit.v1\|wire_search_hits" crates/kebab-cli/tests/

For each match, decide:

  • If the test verifies kebab search --json stdout → update to expect search_response.v1 wrapper.

  • If the test only verifies a single hit's wire shape (still part of the wrapper) → no change.

  • Step 6.6: Commit

git add crates/kebab-cli/src/main.rs crates/kebab-cli/src/wire.rs crates/kebab-app/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(cli): kebab search --max-tokens / --snippet-chars / --cursor (fb-34)

JSON output wrapped in search_response.v1 (breaking — agent must
adapt). Plain output unchanged + [truncated; use --cursor X]
stderr hint when budget tripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: CLI integration tests

Files:

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

  • Step 7.1: Inspect existing common helpers

sed -n '1,50p' crates/kebab-cli/tests/common/mod.rs

Existing fb-32 / fb-33 helpers: write_config(cfg, ws), ingest, run_search_json, etc. Mirror these.

  • Step 7.2: Add run_search helper for arbitrary args

If a generic search runner doesn't exist, append to crates/kebab-cli/tests/common/mod.rs:

/// p9-fb-34: invoke `kebab search` with arbitrary flags, capture
/// stdout + stderr.
pub fn run_search_with_args(cfg: &std::path::Path, args: &[&str]) -> (String, String) {
    let exe = env!("CARGO_BIN_EXE_kebab");
    let mut cmd_args: Vec<&str> = vec!["--config"];
    let cfg_str = cfg.to_str().expect("utf8");
    cmd_args.push(cfg_str);
    cmd_args.push("search");
    cmd_args.extend(args);
    let out = std::process::Command::new(exe)
        .args(&cmd_args)
        .output()
        .expect("kebab search");
    (
        String::from_utf8_lossy(&out.stdout).to_string(),
        String::from_utf8_lossy(&out.stderr).to_string(),
    )
}

Adapt to whatever signature the existing helpers use.

  • Step 7.3: Write the integration tests

Create crates/kebab-cli/tests/wire_search_response.rs:

//! p9-fb-34: CLI search wire wrapper + budget controls.

mod common;

use serde_json::Value;

#[test]
fn search_json_emits_search_response_v1_wrapper() {
    let (cfg, ws) = common::write_config();
    common::ingest(&cfg, &ws, "a.md", "# T\n\napples are red.\n");
    let (stdout, _stderr) = common::run_search_with_args(
        &cfg,
        &["--mode", "lexical", "--json", "apples"],
    );
    let v: Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("not JSON: {stdout:?}: {e}"));
    assert_eq!(v["schema_version"], "search_response.v1");
    assert!(v["hits"].is_array(), "hits must be array");
    assert!(v["next_cursor"].is_null() || v["next_cursor"].is_string());
    assert!(v["truncated"].is_boolean());
}

#[test]
fn search_json_truncates_with_max_tokens() {
    let (cfg, ws) = common::write_config();
    let body: String = "rust ownership is a memory model. ".repeat(10);
    common::ingest(&cfg, &ws, "a.md", &format!("# T\n\n{body}\n"));
    let (stdout, _stderr) = common::run_search_with_args(
        &cfg,
        &["--mode", "lexical", "--json", "--max-tokens", "30", "rust"],
    );
    let v: Value = serde_json::from_str(stdout.trim()).expect("json");
    assert_eq!(v["truncated"], true, "30 tokens cap must trip truncation");
}

#[test]
fn search_json_cursor_paginates() {
    let (cfg, ws) = common::write_config();
    for i in 0..6 {
        common::ingest(&cfg, &ws, &format!("d{i}.md"), &format!("# T{i}\n\nrust topic {i}\n"));
    }
    let (page1, _) = common::run_search_with_args(
        &cfg,
        &["--mode", "lexical", "--json", "-k", "2", "rust"],
    );
    let v1: Value = serde_json::from_str(page1.trim()).expect("json");
    let cursor = v1["next_cursor"].as_str().expect("next_cursor present");

    let (page2, _) = common::run_search_with_args(
        &cfg,
        &["--mode", "lexical", "--json", "-k", "2", "--cursor", cursor, "rust"],
    );
    let v2: Value = serde_json::from_str(page2.trim()).expect("json");
    let p1_ids: Vec<_> = v1["hits"]
        .as_array()
        .unwrap()
        .iter()
        .map(|h| h["chunk_id"].as_str().unwrap().to_string())
        .collect();
    let p2_ids: Vec<_> = v2["hits"]
        .as_array()
        .unwrap()
        .iter()
        .map(|h| h["chunk_id"].as_str().unwrap().to_string())
        .collect();
    assert!(p2_ids.iter().all(|id| !p1_ids.contains(id)),
        "page 2 must not repeat page 1");
}

#[test]
fn search_plain_emits_truncated_hint_to_stderr() {
    let (cfg, ws) = common::write_config();
    let body: String = "rust ownership is a memory model. ".repeat(10);
    common::ingest(&cfg, &ws, "a.md", &format!("# T\n\n{body}\n"));
    let (_stdout, stderr) = common::run_search_with_args(
        &cfg,
        &["--mode", "lexical", "--max-tokens", "30", "rust"],
    );
    assert!(
        stderr.contains("[truncated;"),
        "stderr must carry truncated hint: {stderr:?}"
    );
}

If common::write_config() doesn't exist with the exact signature, look at how wire_search_stale.rs calls it (fb-32) and mirror.

  • Step 7.4: Build + run
cargo test -p kebab-cli --test wire_search_response 2>&1 | tail -20

Expected: 4 PASS. (Lexical-only, no Ollama gate needed.)

  • Step 7.5: Verify full kebab-cli suite
cargo test -p kebab-cli

Expected: all PASS.

  • Step 7.6: Commit
git add crates/kebab-cli/tests/
git commit -m "$(cat <<'EOF'
test(cli): wire_search_response + budget integration (fb-34)

4 lexical-only tests covering search_response.v1 wrapper shape,
--max-tokens truncation, --cursor pagination, plain stderr hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: MCP search tool — wrapper + new inputs

Files:

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

  • Possibly modify: crates/kebab-mcp/tests/tools_call_search.rs

  • Step 8.1: Inspect current MCP search tool

sed -n '1,80p' crates/kebab-mcp/src/tools/search.rs

Note the existing SearchInput shape and the wire-tag pattern used for the response.

  • Step 8.2: Extend SearchInput

Add 3 optional fields:

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SearchInput {
    pub query: String,
    pub mode: Option<String>,
    pub k: Option<usize>,
    /// p9-fb-34: cap result wire size at ~N tokens (chars/4 estimate).
    pub max_tokens: Option<usize>,
    /// p9-fb-34: per-hit snippet character cap.
    pub snippet_chars: Option<usize>,
    /// p9-fb-34: opaque cursor from a previous response.
    pub cursor: Option<String>,
}
  • Step 8.3: Switch dispatch to search_with_opts

In handle(state, input), replace the existing search_with_config(...) call with:

let opts = kebab_core::SearchOpts {
    max_tokens: input.max_tokens,
    snippet_chars: input.snippet_chars,
    cursor: input.cursor,
};
let cfg_clone = (*state.config).clone();
let result = kebab_app::search_with_opts_with_config(cfg_clone, query, opts);

(Use whatever wrapper free function shape kebab-app provides per Task 6 Step 6.2.)

For the success branch, serialize SearchResponse and tag with search_response.v1:

match result {
    Ok(resp) => {
        let v = serde_json::json!({
            "schema_version": "search_response.v1",
            "hits": resp.hits.iter().map(serde_json::to_value).collect::<Result<Vec<_>, _>>()?,
            "next_cursor": resp.next_cursor,
            "truncated": resp.truncated,
        });
        match serde_json::to_string(&v) {
            Ok(json) => to_tool_success(json),
            Err(e) => to_tool_error(&anyhow::anyhow!(e)),
        }
    }
    Err(e) => to_tool_error(&e),
}

If the existing handler returns Result<CallToolResult, ErrorData> rather than CallToolResult directly, adapt.

  • Step 8.4: Update the MCP search test

Open crates/kebab-mcp/tests/tools_call_search.rs. The existing test likely asserts search_hit.v1 on the response array. Update to expect the new wrapper:

// (the existing assertions for individual hits stay; add wrapper assertions)
let v: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(v["schema_version"], "search_response.v1");
assert!(v["hits"].is_array());

If the test asserted arr.as_array().first() on what was a top-level array, change to v["hits"].as_array().unwrap().first().

  • Step 8.5: Run MCP tests
cargo test -p kebab-mcp

Expected: all PASS.

  • Step 8.6: Commit
git add crates/kebab-mcp/
git commit -m "$(cat <<'EOF'
feat(mcp): search tool emits search_response.v1 + budget inputs (fb-34)

SearchInput gains max_tokens / snippet_chars / cursor (all optional).
Output wrapped in search_response.v1 to match CLI; existing
tools_call_search test updated to read v["hits"] instead of the bare
array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 9: Workspace test + clippy gate

  • Step 9.1: Workspace test
cargo test --workspace --no-fail-fast -j 1 2>&1 | tail -30

Expected: all PASS.

If any other crate (kebab-tui, kebab-eval, etc.) hits compile errors due to the App::search API surface change, that signals the change wasn't backwards-compatible. Verify App::search signature is unchanged (still Vec<SearchHit>).

  • Step 9.2: Clippy
cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10

Expected: clean. Common new warnings to watch:

  • clippy::needless_pass_by_value on cursor params — adjust as flagged.

  • clippy::large_struct_passed_by_value if SearchOpts grows — currently 3 small Options.

  • Step 9.3: Commit clippy fixes if needed

git add -A
git commit -m "chore: clippy fixes for fb-34"

(Skip if no fixes were necessary.)


Task 10: Documentation updates

Files:

  • Modify: README.md

  • Modify: docs/SMOKE.md

  • Modify: tasks/p9/p9-fb-34-output-budget-controls.md

  • Modify: tasks/INDEX.md

  • Modify: tasks/HOTFIXES.md

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

  • Step 10.1: README — search row update

Find the kebab search row in the 명령 table:

grep -n "kebab search" README.md | head -3

Append --max-tokens, --snippet-chars, --cursor to the flag list and add a one-liner about wire shape change. Example:

| `kebab search "<query>" [--mode lexical|vector|hybrid] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>]` | (existing description) **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. |
  • Step 10.2: SMOKE.md — pagination walkthrough

Append a section after the existing search section (and after the fb-32 / fb-33 sections):

### Pagination + budget (fb-34)

```bash
# First page
kebab search "rust" --json -k 5 > page1.json
jq '.next_cursor' page1.json

# Next page using the returned cursor
NEXT=$(jq -r '.next_cursor' page1.json)
kebab search "rust" --json -k 5 --cursor "$NEXT" > page2.json

# Budget cap — returns smaller snippet / fewer hits + truncated=true
kebab search "rust" --json --max-tokens 200 | jq '.truncated, (.hits | length)'

next_cursor 는 corpus_revision 변경 (이후 ingest 등) 시 invalid — 다음 호출이 error.v1.code = stale_cursor 로 거절. agent 는 새 search 로 재발급 받기.


- [ ] **Step 10.3: Task spec status flip**

Edit `tasks/p9/p9-fb-34-output-budget-controls.md`:

```diff
-status: open
+status: completed

Replace the > ⏳ **백로그 only — 미구현.** block with:

> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-09 — p9-fb-34` 항목 참조 — live source of truth.

상세 설계: `docs/superpowers/specs/2026-05-09-p9-fb-34-output-budget-controls-design.md`.
구현 계획: `docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md`.
  • Step 10.4: tasks/INDEX.md
-    - [p9-fb-34 output budget controls](p9/p9-fb-34-output-budget-controls.md) — ⏳ 미구현, brainstorm 필요
+    - [p9-fb-34 output budget controls](p9/p9-fb-34-output-budget-controls.md) — ✅ 머지 + v0.5.0 cut 후보 (2026-05-09)
  • Step 10.5: HOTFIXES — wire breaking decision log

Add a new entry near the top of dated entries in tasks/HOTFIXES.md:

## 2026-05-09 — p9-fb-34: search wire wrapped in search_response.v1

**무엇이 바뀌었나**: `kebab search --json` stdout 이 기존 `search_hit.v1[]` 배열에서 신규 `search_response.v1` object 로 교체. wrapper 가 `hits`, `next_cursor`, `truncated` 세 필드를 가짐.

**Spec contract 와의 관계**: 명시적 wire breaking change. spec `docs/superpowers/specs/2026-05-09-p9-fb-34-output-budget-controls-design.md` 의 §Wire shape 절에 단일 출처 결정.

**의식적 결정**:
- pagination + truncation metadata 를 `search_hit` 자체에 흡수하면 단일 hit 의 도메인 의미가 오염됨 (모든 hit 가 `next_cursor` 필드 보유 등). top-level wrapper 가 분리도 깨끗.
- 외부 consumer 영향: 단일 사용자 환경 + Claude Code skill 한 곳. skill 은 fb-34 와 동시 갱신.
- 이 변경은 search_hit.v1 자체 schema 는 손대지 않음 — 도메인 stable.

**영향 받는 consumer**: kebab-tui (Search 패널 — 변경 불필요, App::search 시그니처 보존), kebab-mcp (search tool — 같은 PR 에서 갱신), Claude Code skill (같은 PR 에서 갱신). 외부 producer/consumer 없음.
  • Step 10.6: SKILL.md — recipe + cursor example

Edit integrations/claude-code/kebab/SKILL.md. Find the search recipes / parsing tips and update:

  • Recipe A / B / C: response.hits[] instead of bare array. Example:

    jq '.hits[] | {rank, doc_path, heading: .heading_path[-1], snippet}'
    
  • Add a "Pagination" subsection under Parsing tips:

    - `search_response.v1.next_cursor` — opaque base64. Pass back as `--cursor` (CLI) or `cursor` (MCP `mcp__kebab__search` input) for the next page. `null` when no more hits. `corpus_revision` mismatch returns `error.v1.code = stale_cursor` — re-issue the search to obtain a fresh cursor.
    - `search_response.v1.truncated` — true when `--max-tokens` (CLI) / `max_tokens` (MCP) forced snippet shortening or k reduction. Either widen the budget or paginate via `next_cursor`.
    
  • Step 10.7: Commit docs

git add README.md docs/SMOKE.md tasks/p9/p9-fb-34-output-budget-controls.md tasks/INDEX.md tasks/HOTFIXES.md integrations/claude-code/kebab/SKILL.md
git commit -m "$(cat <<'EOF'
docs(fb-34): README + SMOKE + INDEX + HOTFIXES + skill notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 11: Smoke + push + PR

  • Step 11.1: Manual smoke
cd /tmp/kebab-smoke   # existing scratch dir from prior tasks
~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml ingest
~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml search "test" --json | jq '{schema_version, truncated, next_cursor, hit_count: (.hits | length)}'
~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml search "test" --json --max-tokens 30 | jq '.truncated'

Expected:

  • First call: schema_version: "search_response.v1", truncated: false, hit_count > 0.

  • Second call: truncated: true.

  • Step 11.2: Final workspace test

cd ~/Workspace/projects/kebab
cargo test --workspace --no-fail-fast -j 1

Expected: all green.

  • Step 11.3: Push branch
git push -u origin feat/fb-34-output-budget-controls
  • Step 11.4: Open PR via gitea-pr

Build the PR body at /tmp/fb34-pr-body.md:

## Summary

- adds `kebab search --max-tokens / --snippet-chars / --cursor` plus the equivalent inputs on `mcp__kebab__search`
- wraps `--json` output in `search_response.v1` (`{hits, next_cursor, truncated}`) — wire breaking; agent that parses bare `search_hit.v1[]` must adapt
- token estimation = `chars/4` (no tokenizer dep); truncate priority: snippet shorten → k pop → ≥1 hit floor
- cursor = opaque base64(`{offset, corpus_revision}`); mismatch returns `error.v1.code = stale_cursor`
- ask path scope out (rag.max_context_tokens already covers it)
- TUI Search pane unchanged — `App::search` signature preserved as thin wrapper

## Test plan

- [x] `cargo test --workspace --no-fail-fast -j 1` — green
- [x] `cargo clippy --workspace --all-targets -- -D warnings` — clean
- [x] new tests:
  - `cursor` (kebab-app): encode/decode round-trip + stale_cursor mismatch (3 tests)
  - `search_budget_integration` (kebab-app): passthrough + snippet shorten + cursor pagination + corpus_revision bump (4 tests)
  - `wire_search_response` (kebab-cli): wire wrapper + max-tokens truncation + cursor pagination + plain stderr hint (4 tests)
  - `tools_call_search` (kebab-mcp): updated to assert `search_response.v1` wrapper
- [x] manual smoke per `docs/SMOKE.md` "Pagination + budget" walkthrough

## Architectural notes

- `App::search` signature unchanged → TUI / kebab-rag callers unaffected.
- `App::search_with_opts` is the new public API; CLI / MCP go through it.
- `chars/4` token estimation matches `rag::pack_context` convention.
- Cursor is opaque on purpose — internal schema may change; agent must not parse.
- Wire breaking documented in HOTFIXES `2026-05-09 — p9-fb-34`.

## Files of interest

- spec: `docs/superpowers/specs/2026-05-09-p9-fb-34-output-budget-controls-design.md`
- plan: `docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md`
- core: `crates/kebab-core/src/search.rs` (SearchOpts)
- app: `crates/kebab-app/src/{cursor,app}.rs` (SearchResponse + budget loop)
- CLI: `crates/kebab-cli/src/main.rs` (Cmd::Search), `crates/kebab-cli/src/wire.rs`
- MCP: `crates/kebab-mcp/src/tools/search.rs`
- wire: `docs/wire-schema/v1/search_response.schema.json`

Open the PR:

/Users/user/.claude/skills/gitea-ops/bin/gitea-pr \
  --title "feat(fb-34): output budget controls" \
  --body "$(cat /tmp/fb34-pr-body.md)" \
  --head feat/fb-34-output-budget-controls \
  --base main

Capture the URL.

  • Step 11.5: Cleanup
rm /tmp/fb34-pr-body.md

Self-review

  • Spec coverage:

    • §Behavior contract / CLI flags → Task 6
    • §Wire shape → Task 5 (schema) + Task 6 (CLI emit) + Task 8 (MCP emit)
    • §Token estimation → Task 4 (estimate_chars helper using serde_json size, chars/4 conceptually)
    • §Truncate priority → Task 4 budget loop (snippet shorten → k pop → ≥1)
    • §Pagination cursor → Task 2 (encode/decode) + Task 4 (next_cursor computation) + Task 6 (CLI flag) + Task 8 (MCP input)
    • §Stale cursor error → Task 2 + Task 3
    • §Domain API change → Tasks 1, 4 (SearchOpts + SearchResponse + App::search_with_opts)
    • §Components → Tasks 1-8
    • §Test plan → Tasks 2 (cursor), 4 (App), 7 (CLI), 8 (MCP)
    • §Documentation → Task 10
    • §Risks (wire breaking, App stability, chars/4 ±15%, cursor opacity) → addressed in Task 4 (App::search preserved), Task 5 (schema description mentions approximation), Task 10 (HOTFIXES)
  • Placeholder scan:

    • Two "if/look at" instructions in Task 6 + Task 8 — those direct the engineer to mirror existing scaffold rather than invent. Concrete fallback paths spelled out.
    • No TODO / "fill in" / "later".
  • Type consistency:

    • SearchOpts { max_tokens: Option<usize>, snippet_chars: Option<usize>, cursor: Option<String> } consistent across Tasks 1, 4, 6, 8.
    • SearchResponse { hits: Vec<SearchHit>, next_cursor: Option<String>, truncated: bool } consistent across Tasks 4, 5, 6, 8.
    • cursor::encode(offset, revision) -> String, cursor::decode(s, expected) -> Result<usize, ErrorV1> consistent across Tasks 2, 4.
    • error.v1.code = "stale_cursor" consistent across spec, Task 2, Task 3, Task 10.

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md. Two execution options:

1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks.

2. Inline Execution — execute tasks in this session.

Which approach?