From dbb7b54d5d49a7f142bfaa05890e446339a360bc Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sat, 9 May 2026 17:43:26 +0900 Subject: [PATCH] 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) --- ...6-05-09-p9-fb-34-output-budget-controls.md | 1535 +++++++++++++++++ 1 file changed, 1535 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md diff --git a/docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md b/docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md new file mode 100644 index 0000000..491acf0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-p9-fb-34-output-budget-controls.md @@ -0,0 +1,1535 @@ +# 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` 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` | 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** + +```bash +git checkout main +git pull +git checkout -b feat/fb-34-output-budget-controls +``` + +- [ ] **Step 0.2: Confirm spec branch reachable** + +```bash +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): + +```rust +#[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** + +```bash +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)]`): + +```rust +/// p9-fb-34: caller-supplied output budget knobs for `App::search_with_opts`. +/// All `None` = no enforcement (existing behavior). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SearchOpts { + /// chars/4 approximation of wire JSON token cost. None = no cap. + pub max_tokens: Option, + /// Per-hit snippet character cap. None = use config default. + pub snippet_chars: Option, + /// Opaque base64 cursor from a previous response. None = first page. + pub cursor: Option, +} +``` + +- [ ] **Step 1.4: Re-export from `crates/kebab-core/src/lib.rs`** + +Find the existing `pub use search::{...}` line: + +```bash +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** + +```bash +cargo test -p kebab-core search_opts_default_is_all_none +cargo test -p kebab-core +``` + +Expected: PASS. + +- [ ] **Step 1.6: Commit** + +```bash +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) +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`: + +```bash +grep -n "^base64" Cargo.toml +``` + +If absent, add to `[workspace.dependencies]`: + +```toml +base64 = "0.22" +``` + +Then add to `crates/kebab-app/Cargo.toml` `[dependencies]`: + +```toml +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`: + +```rust +//! 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** + +```bash +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`: + +```rust +//! 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 { + let bytes = URL_SAFE_NO_PAD.decode(s.as_bytes()).map_err(|_| stale( + "", + expected_revision, + ))?; + let payload: Payload = serde_json::from_slice(&bytes).map_err(|_| stale( + "", + 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: + +```rust +pub mod cursor; +``` + +(Use `pub mod` so `cursor::encode` / `cursor::decode` are reachable from the integration test.) + +- [ ] **Step 2.6: Run tests — verify pass** + +```bash +cargo test -p kebab-app --test cursor +``` + +Expected: 3 PASS. + +- [ ] **Step 2.7: Commit** + +```bash +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) +EOF +)" +``` + +--- + +## Task 3: `error_wire` — `stale_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`: + +```rust +#[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** + +```bash +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`: + +```rust +// 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** + +```bash +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) +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`: + +```rust +//! 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** + +```bash +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: + +```rust +/// 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, + pub next_cursor: Option, + pub truncated: bool, +} +``` + +Then in `impl App`, add: + +```rust +/// 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 { + 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 = 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): + +```rust +/// 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** + +```bash +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`: + +```rust +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`: + +```rust +#[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** + +```bash +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`: + +```rust +#[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** + +```bash +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** + +```bash +cargo test -p kebab-app +cargo build --workspace +``` + +Expected: green. `App::search` signature unchanged so TUI / kebab-rag callers compile. + +- [ ] **Step 4.11: Commit** + +```bash +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) +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`: + +```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** + +```bash +python3 -c "import json; json.load(open('docs/wire-schema/v1/search_response.schema.json'))" +``` + +Expected: silent success. + +- [ ] **Step 5.3: Commit** + +```bash +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) +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: + +```rust +/// 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::>(), + "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`: + +```bash +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): + +```rust +/// 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, +/// p9-fb-34: per-hit snippet character cap, overrides +/// `config.search.snippet_chars` for this call only. +#[arg(long)] +snippet_chars: Option, +/// 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, +``` + +In the match arm, replace the existing dispatch (around `Cmd::Search { query, k, mode, explain: _, no_cache } =>`): + +```rust +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: + +```bash +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: + +```rust +pub fn search_with_opts_with_config( + config: kebab_config::Config, + query: SearchQuery, + opts: SearchOpts, +) -> anyhow::Result { + App::open_with_config(config)?.search_with_opts(query, opts) +} +``` + +- [ ] **Step 6.3: Build the CLI** + +```bash +cargo build -p kebab-cli +``` + +Expected: clean. + +- [ ] **Step 6.4: Verify --help shows the new flags** + +```bash +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** + +```bash +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: + +```bash +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** + +```bash +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) +EOF +)" +``` + +--- + +## Task 7: CLI integration tests + +**Files:** +- Create: `crates/kebab-cli/tests/wire_search_response.rs` + +- [ ] **Step 7.1: Inspect existing common helpers** + +```bash +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`: + +```rust +/// 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`: + +```rust +//! 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** + +```bash +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** + +```bash +cargo test -p kebab-cli +``` + +Expected: all PASS. + +- [ ] **Step 7.6: Commit** + +```bash +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) +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** + +```bash +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: + +```rust +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct SearchInput { + pub query: String, + pub mode: Option, + pub k: Option, + /// p9-fb-34: cap result wire size at ~N tokens (chars/4 estimate). + pub max_tokens: Option, + /// p9-fb-34: per-hit snippet character cap. + pub snippet_chars: Option, + /// p9-fb-34: opaque cursor from a previous response. + pub cursor: Option, +} +``` + +- [ ] **Step 8.3: Switch dispatch to `search_with_opts`** + +In `handle(state, input)`, replace the existing `search_with_config(...)` call with: + +```rust +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`: + +```rust +match result { + Ok(resp) => { + let v = serde_json::json!({ + "schema_version": "search_response.v1", + "hits": resp.hits.iter().map(serde_json::to_value).collect::, _>>()?, + "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` 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: + +```rust +// (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** + +```bash +cargo test -p kebab-mcp +``` + +Expected: all PASS. + +- [ ] **Step 8.6: Commit** + +```bash +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) +EOF +)" +``` + +--- + +## Task 9: Workspace test + clippy gate + +- [ ] **Step 9.1: Workspace test** + +```bash +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`). + +- [ ] **Step 9.2: Clippy** + +```bash +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** + +```bash +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: + +```bash +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: + +```markdown +| `kebab search "" [--mode lexical|vector|hybrid] [--max-tokens N] [--snippet-chars N] [--cursor ]` | (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): + +```markdown +### 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: + +```markdown +> ✅ **구현 완료.** 본 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** + +```diff +- - [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`: + +```markdown +## 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 + jq '.hits[] | {rank, doc_path, heading: .heading_path[-1], snippet}' + ``` +- Add a "Pagination" subsection under Parsing tips: + ```markdown + - `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** + +```bash +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) +EOF +)" +``` + +--- + +## Task 11: Smoke + push + PR + +- [ ] **Step 11.1: Manual smoke** + +```bash +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** + +```bash +cd ~/Workspace/projects/kebab +cargo test --workspace --no-fail-fast -j 1 +``` + +Expected: all green. + +- [ ] **Step 11.3: Push branch** + +```bash +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`: + +```markdown +## 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: + +```bash +/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** + +```bash +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, snippet_chars: Option, cursor: Option }` consistent across Tasks 1, 4, 6, 8. + - `SearchResponse { hits: Vec, next_cursor: Option, truncated: bool }` consistent across Tasks 4, 5, 6, 8. + - `cursor::encode(offset, revision) -> String`, `cursor::decode(s, expected) -> Result` 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?