docs(p9): decompose dogfooding feedback into 20 task specs + reset plan

P9-1~P9-4 머지 후 사용자가 직접 도그푸딩 하며 수집한 16 항목 UX
피드백을 20 개 single-PR 사이즈 task spec 으로 분해. 각 spec 은
frontmatter (depends_on / unblocks / source_feedback), Goal,
Allowed deps, Public surface, Behavior contract, Test plan, DoD,
Out of scope 절 포함.

추가:
- p9-fb-01 ~ 20-*.md: 분해된 task spec 20 개
- p9-dogfooding-feedback.md: master index + 우선순위 + 권장 실행 순서
  + spec PR vs impl PR 절
- INDEX.md: p9-fb-01 ~ 20 link 추가
- docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md:
  첫 후속 작업 (kebab reset 명령) 의 6-task 구현 plan
- .gitignore: .worktrees/ 추가 (superpowers worktree skill 용)

피드백 항목 → task spec 매핑은 p9-dogfooding-feedback.md 의 표 참조.
실행 시작 task: p9-fb-06 (reset 명령) — 도그푸딩 막힘 강도 1위.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 17:54:15 +00:00
parent 8691bfe381
commit 5428412688
24 changed files with 2660 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.superpowers/
.worktrees/
/target/
**/*.rs.bk
Cargo.lock.bak

View File

@@ -0,0 +1,994 @@
# p9-fb-06 — `kebab reset` 명령 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 `kebab reset [--all|--data-only|--vector-only|--config-only] [--yes]` CLI command that wipes XDG dirs (and optionally just the Lance vector store + matching SQLite `embedding_records` rows) with a TTY confirm gate.
**Architecture:** New `kebab-app::reset` module owns the wipe logic (path resolve via existing `Config::xdg_*` + `expand_path`, `fs::remove_dir_all`, optional `embedding_records` truncate via a new `kebab-store-sqlite` helper). `kebab-cli` adds the `Reset` subcommand, a self-contained 20-line stdin/stdout confirm prompt (no new deps), and a `reset_report.v1` wire schema. `kebab init` is NOT auto-called — user re-runs explicitly.
**Tech Stack:** Rust 2024, clap (existing), `std::io::IsTerminal` (stdlib), `std::fs::remove_dir_all`, rusqlite (`DELETE FROM embedding_records`), serde_json for wire output.
---
## File Structure
**Create:**
- `crates/kebab-app/src/reset.rs` — wipe logic + scope resolution
- `crates/kebab-store-sqlite/tests/truncate_embeddings.rs` — integration test
- `crates/kebab-cli/tests/reset_cli.rs` — integration test
- `docs/wire-schema/v1/reset_report.schema.json` — JSON Schema 7
**Modify:**
- `crates/kebab-app/src/lib.rs``pub mod reset;` + re-export `ResetScope` / `ResetReport`
- `crates/kebab-store-sqlite/src/embeddings.rs``pub fn truncate_embedding_records()`
- `crates/kebab-cli/src/main.rs` — add `Cmd::Reset` arm + handler
- `crates/kebab-cli/src/wire.rs``wire_reset` helper
- `README.md``kebab reset` in 명령 표 + Quick start safety note
- `tasks/HOTFIXES.md` — n/a (new feature, not deviation; skip)
**Delete:** none.
---
## Task 1: `kebab-store-sqlite::truncate_embedding_records`
**Files:**
- Create: `crates/kebab-store-sqlite/tests/truncate_embeddings.rs`
- Modify: `crates/kebab-store-sqlite/src/embeddings.rs` (append helper at end of `impl SqliteStore`)
- [ ] **Step 1: Write the failing test**
```rust
// crates/kebab-store-sqlite/tests/truncate_embeddings.rs
//! `truncate_embedding_records` wipes every row regardless of status.
//!
//! Used by `kebab reset --vector-only` to keep SQLite in sync after the
//! Lance vector store is deleted off-disk.
use kebab_core::ChunkId;
use kebab_store_sqlite::{SqliteStore, embeddings::EmbeddingRecordRow};
use time::OffsetDateTime;
fn tmp_store() -> (tempfile::TempDir, SqliteStore) {
let dir = tempfile::tempdir().unwrap();
let store = SqliteStore::open(&dir.path().join("kebab.sqlite")).unwrap();
(dir, store)
}
fn count_embedding_rows(store: &SqliteStore) -> i64 {
store
.with_conn(|c| {
c.query_row("SELECT COUNT(*) FROM embedding_records", [], |r| r.get(0))
.map_err(Into::into)
})
.unwrap()
}
#[test]
fn truncate_removes_all_rows() {
let (_dir, store) = tmp_store();
// Seed via the existing public API. We don't need real chunks for the
// count assertion — embedding_records has no FK back to chunks under
// the V003 schema.
let row = EmbeddingRecordRow {
embedding_id: "e1".into(),
chunk_id: "c1".into(),
model_id: "test-model".into(),
model_version: "v1".into(),
dimensions: 4,
lance_table: "test_table".into(),
created_at: OffsetDateTime::now_utc(),
};
store.put_embedding_records_pending(&[row]).unwrap();
assert_eq!(count_embedding_rows(&store), 1);
store.truncate_embedding_records().unwrap();
assert_eq!(count_embedding_rows(&store), 0);
}
#[test]
fn truncate_on_empty_table_is_noop() {
let (_dir, store) = tmp_store();
store.truncate_embedding_records().unwrap();
assert_eq!(count_embedding_rows(&store), 0);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p kebab-store-sqlite --test truncate_embeddings`
Expected: FAIL — `truncate_embedding_records` not in scope on `SqliteStore`.
(If `with_conn` also doesn't exist as a public test seam, fall back to opening a raw `rusqlite::Connection` via the same path the store used. Check first with `grep -n "with_conn\|pub fn" crates/kebab-store-sqlite/src/store.rs`. If absent, replace `count_embedding_rows` body with a fresh `rusqlite::Connection::open(dir.path().join("kebab.sqlite"))` query — no production-only test seam.)
- [ ] **Step 3: Implement**
Append at the end of `impl SqliteStore` in `crates/kebab-store-sqlite/src/embeddings.rs`:
```rust
/// Wipe every row from `embedding_records`. Called by `kebab reset
/// --vector-only` so SQLite cannot point at a Lance row that the
/// reset just removed off-disk. The function does NOT cascade to
/// `chunks` or `documents` — those are kept so the next `kebab
/// ingest` can re-embed the existing chunk set without re-parsing.
pub fn truncate_embedding_records(&self) -> Result<()> {
let conn = self.lock_conn();
conn.execute("DELETE FROM embedding_records", [])
.context("DELETE FROM embedding_records")?;
Ok(())
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p kebab-store-sqlite --test truncate_embeddings`
Expected: PASS — both tests green.
- [ ] **Step 5: Run the existing crate tests to confirm no regression**
Run: `cargo test -p kebab-store-sqlite`
Expected: full suite PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/kebab-store-sqlite/src/embeddings.rs \
crates/kebab-store-sqlite/tests/truncate_embeddings.rs
git commit -m "feat(store-sqlite): add truncate_embedding_records helper
Used by upcoming \`kebab reset --vector-only\` to keep SQLite in sync
after the on-disk Lance store is removed. p9-fb-06 task 1."
```
---
## Task 2: `kebab-app::reset` module — scope + path resolution + wipe
**Files:**
- Create: `crates/kebab-app/src/reset.rs`
- Modify: `crates/kebab-app/src/lib.rs` (add `pub mod reset;` + re-export)
- Modify: `crates/kebab-app/Cargo.toml` (add `dev-dependencies.tempfile` if missing)
The unit tests for path estimation live in this same task; integration ("did the dir actually disappear?") moves up to the CLI test in Task 4.
- [ ] **Step 1: Check whether `tempfile` is already a dev-dep**
Run: `grep -n "tempfile" crates/kebab-app/Cargo.toml`
Expected: present in `[dev-dependencies]` (it's used elsewhere). If missing, add:
```toml
[dev-dependencies]
tempfile = "3"
```
- [ ] **Step 2: Write the failing test (path estimation)**
Create `crates/kebab-app/src/reset.rs` with the test stub at the bottom:
```rust
//! `kebab reset` core — scope-driven path enumeration + wipe.
//!
//! The CLI (and any future TUI surface) calls `enumerate_paths(scope, &cfg)`
//! to compute exactly which on-disk paths the user has asked to remove,
//! presents that list for confirmation, then calls `execute(scope, &cfg)`
//! to actually remove them. Splitting the read step (enumerate) from the
//! write step (execute) is what lets the confirm UI show a faithful
//! preview without having to re-derive the path set.
//!
//! `--vector-only` additionally truncates `embedding_records` in SQLite
//! so the next `kebab ingest` re-embeds cleanly without orphan rows.
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use kebab_config::{Config, expand_path};
/// What the user asked to remove. Mutually exclusive — picked by the CLI
/// from a clap `ArgGroup`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResetScope {
/// Wipe config + data + cache + state (all four XDG dirs).
All,
/// Wipe data + cache + state. Config is preserved so the next run
/// behaves the same. Default when the user passes `--data-only`.
DataOnly,
/// Wipe only the Lance vector_dir off-disk AND truncate the matching
/// `embedding_records` rows in SQLite. Documents / chunks survive.
VectorOnly,
/// Wipe only the config dir.
ConfigOnly,
}
/// Result of a successful wipe — emitted as `reset_report.v1` by the
/// CLI's `--json` mode and used by the human-mode summary line.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResetReport {
pub scope: ResetScope,
pub removed_paths: Vec<PathBuf>,
pub embedding_rows_truncated: u64,
}
/// Compute the absolute on-disk paths a given scope will wipe, given a
/// loaded `Config`. Pure — does NOT touch the filesystem.
///
/// `--all` returns all four XDG paths in a stable order (config, data,
/// cache, state). `--vector-only` returns the resolved `storage.vector_dir`.
/// Order is preserved across calls so the confirm UI is deterministic.
pub fn enumerate_paths(scope: ResetScope, cfg: &Config) -> Vec<PathBuf> {
let cfg_dir = Config::xdg_config_path()
.parent()
.map(PathBuf::from)
.unwrap_or_default();
let data_dir = Config::xdg_data_dir();
let cache_dir = Config::xdg_cache_dir();
let state_dir = Config::xdg_state_dir();
match scope {
ResetScope::All => vec![cfg_dir, data_dir, cache_dir, state_dir],
ResetScope::DataOnly => vec![data_dir, cache_dir, state_dir],
ResetScope::VectorOnly => {
let vector_dir =
expand_path(&cfg.storage.vector_dir, &data_dir.to_string_lossy());
vec![vector_dir]
}
ResetScope::ConfigOnly => vec![cfg_dir],
}
}
/// Best-effort byte size of a directory tree (returns 0 on any I/O error
/// — this is for the confirm UI, not accounting). Skips broken symlinks
/// instead of bubbling errors so a half-broken cache still gets summed.
pub fn estimate_size_bytes(paths: &[PathBuf]) -> u64 {
fn walk(p: &std::path::Path) -> u64 {
let mut total = 0u64;
let entries = match std::fs::read_dir(p) {
Ok(it) => it,
Err(_) => return 0,
};
for e in entries.flatten() {
let ft = match e.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_dir() {
total += walk(&e.path());
} else if ft.is_file() {
total += e.metadata().map(|m| m.len()).unwrap_or(0);
}
}
total
}
paths.iter().map(|p| walk(p)).sum()
}
/// Wipe every path from `enumerate_paths(scope, cfg)`. For
/// `ResetScope::VectorOnly`, also truncates the SQLite
/// `embedding_records` table so the store doesn't point at the Lance
/// rows we just removed off-disk.
///
/// Idempotent: a missing path is treated as already-removed (success).
/// Returns a `ResetReport` listing exactly what was removed (paths that
/// existed before the call) so `--json` callers see the truth, not the
/// request.
pub fn execute(scope: ResetScope, cfg: &Config) -> Result<ResetReport> {
let paths = enumerate_paths(scope, cfg);
let mut removed = Vec::new();
for p in &paths {
if !p.exists() {
continue;
}
std::fs::remove_dir_all(p)
.with_context(|| format!("remove {}", p.display()))?;
removed.push(p.clone());
}
let embedding_rows_truncated = if matches!(scope, ResetScope::VectorOnly) {
truncate_embeddings(cfg)?
} else {
0
};
Ok(ResetReport {
scope,
removed_paths: removed,
embedding_rows_truncated,
})
}
/// Open the SQLite store at the configured path and run
/// `truncate_embedding_records`. Returns the row count BEFORE truncation
/// so the wire report can surface it. If the SQLite file does not exist
/// (e.g. user has never ingested), returns 0 — not an error.
fn truncate_embeddings(cfg: &Config) -> Result<u64> {
let data_dir = Config::xdg_data_dir();
let sqlite_path =
expand_path(&cfg.storage.sqlite, &data_dir.to_string_lossy());
if !sqlite_path.exists() {
return Ok(0);
}
let store = kebab_store_sqlite::SqliteStore::open(&sqlite_path)
.context("open SqliteStore for truncate_embedding_records")?;
// Count first so the report is meaningful.
let before: i64 = {
let conn = store.lock_conn();
conn.query_row("SELECT COUNT(*) FROM embedding_records", [], |r| r.get(0))
.unwrap_or(0)
};
store.truncate_embedding_records()?;
Ok(u64::try_from(before).unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with_vector_dir(s: &str) -> Config {
let mut c = Config::defaults();
c.storage.vector_dir = s.to_string();
c
}
#[test]
fn enumerate_data_only_excludes_config_dir() {
let cfg = Config::defaults();
let paths = enumerate_paths(ResetScope::DataOnly, &cfg);
let cfg_dir = Config::xdg_config_path()
.parent()
.map(PathBuf::from)
.unwrap_or_default();
assert!(!paths.contains(&cfg_dir));
}
#[test]
fn enumerate_vector_only_returns_resolved_vector_dir() {
let cfg = cfg_with_vector_dir("{data_dir}/lancedb");
let paths = enumerate_paths(ResetScope::VectorOnly, &cfg);
assert_eq!(paths.len(), 1);
let s = paths[0].to_string_lossy().into_owned();
assert!(s.ends_with("/lancedb"), "got: {s}");
}
#[test]
fn enumerate_all_has_four_distinct_paths() {
let cfg = Config::defaults();
let paths = enumerate_paths(ResetScope::All, &cfg);
assert_eq!(paths.len(), 4);
// Distinct — XDG layout puts each in its own subtree.
let unique: std::collections::HashSet<_> = paths.iter().collect();
assert_eq!(unique.len(), 4);
}
#[test]
fn estimate_size_returns_zero_on_missing_dir() {
assert_eq!(estimate_size_bytes(&[PathBuf::from("/nonexistent/xyz")]), 0);
}
#[test]
fn execute_data_only_removes_dir_and_returns_report() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("kebab-data");
std::fs::create_dir_all(target.join("inner")).unwrap();
std::fs::write(target.join("inner/x"), b"y").unwrap();
assert!(target.exists());
// Drive `execute` with a synthetic enumerate result by overriding
// the XDG env var so `xdg_data_dir()` returns our temp path.
// The other three XDG dirs we point at fresh subdirs so the test
// is self-contained.
let _g_data = scoped_env("XDG_DATA_HOME", dir.path().join("data"));
let _g_cfg = scoped_env("XDG_CONFIG_HOME", dir.path().join("cfg"));
let _g_cache = scoped_env("XDG_CACHE_HOME", dir.path().join("cache"));
let _g_state = scoped_env("XDG_STATE_HOME", dir.path().join("state"));
std::fs::create_dir_all(dir.path().join("data/kebab")).unwrap();
std::fs::write(dir.path().join("data/kebab/marker"), b"hi").unwrap();
let cfg = Config::defaults();
let report = execute(ResetScope::DataOnly, &cfg).unwrap();
assert_eq!(report.scope, ResetScope::DataOnly);
// data dir was the only one that actually existed → only one
// entry in `removed_paths`.
assert_eq!(report.removed_paths.len(), 1);
assert!(!dir.path().join("data/kebab").exists());
}
/// Scoped env var setter that restores the previous value on drop.
/// Tests run sequentially per binary by default, but we restore to
/// be polite to anyone who switches `--test-threads`.
fn scoped_env(key: &str, val: std::path::PathBuf) -> EnvGuard {
let prev = std::env::var(key).ok();
// SAFETY: tests in this module run single-threaded under the
// default cargo test runner; this is the same pattern used in
// `kebab-config::xdg_paths_honor_env`.
unsafe { std::env::set_var(key, &val) };
EnvGuard { key: key.to_string(), prev }
}
struct EnvGuard {
key: String,
prev: Option<String>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var(&self.key, v),
None => std::env::remove_var(&self.key),
}
}
}
}
}
```
- [ ] **Step 3: Wire the new module into the crate root**
Edit `crates/kebab-app/src/lib.rs`. Find the existing `mod` declarations near the top of the file (search for `pub mod doctor_signal`) and add:
```rust
pub mod reset;
pub use reset::{ResetReport, ResetScope};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p kebab-app reset`
Expected: 5 PASS — `enumerate_data_only_excludes_config_dir`, `enumerate_vector_only_returns_resolved_vector_dir`, `enumerate_all_has_four_distinct_paths`, `estimate_size_returns_zero_on_missing_dir`, `execute_data_only_removes_dir_and_returns_report`.
If `lock_conn` / `SqliteStore::open` signatures don't match the lookup we did during planning, fix the call sites in `truncate_embeddings` to whatever the real surface is — verify with `grep -n "pub fn open\|pub fn lock_conn" crates/kebab-store-sqlite/src/store.rs`.
- [ ] **Step 5: Commit**
```bash
git add crates/kebab-app/src/reset.rs crates/kebab-app/src/lib.rs
git commit -m "feat(app): add reset module — scope, path enumeration, execute
Provides the wipe core for \`kebab reset\`. Mutually-exclusive
ResetScope variants (All / DataOnly / VectorOnly / ConfigOnly),
pure path enumeration for confirm UI, and an execute helper that
removes paths + truncates embedding_records when scope is VectorOnly.
p9-fb-06 task 2."
```
---
## Task 3: Wire schema `reset_report.v1`
**Files:**
- Create: `docs/wire-schema/v1/reset_report.schema.json`
- Modify: `crates/kebab-cli/src/wire.rs` (add `wire_reset` helper + test)
- [ ] **Step 1: Create the JSON Schema 7 document**
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://kebab.local/wire-schema/v1/reset_report.schema.json",
"title": "reset_report.v1",
"description": "Result of `kebab reset` — what scope was requested and what was actually removed off-disk.",
"type": "object",
"required": ["schema_version", "scope", "removed_paths", "embedding_rows_truncated"],
"additionalProperties": false,
"properties": {
"schema_version": { "const": "reset_report.v1" },
"scope": {
"type": "string",
"enum": ["all", "data_only", "vector_only", "config_only"]
},
"removed_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Absolute paths that existed before the call and have now been removed. A path that did not exist beforehand is omitted (the wipe is idempotent)."
},
"embedding_rows_truncated": {
"type": "integer",
"minimum": 0,
"description": "Count of rows wiped from SQLite embedding_records. Always 0 unless scope is vector_only."
}
}
}
```
- [ ] **Step 2: Write the failing test**
Append to `crates/kebab-cli/src/wire.rs` (in the `#[cfg(test)] mod tests` block):
```rust
#[test]
fn reset_wrapper_tags_schema_version() {
let r = kebab_app::ResetReport {
scope: kebab_app::ResetScope::DataOnly,
removed_paths: vec![std::path::PathBuf::from("/tmp/x")],
embedding_rows_truncated: 0,
};
let v = wire_reset(&r);
assert_eq!(schema_of(&v), Some("reset_report.v1"));
assert_eq!(
v.get("scope").and_then(Value::as_str),
Some("data_only")
);
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `cargo test -p kebab-cli wire::tests::reset_wrapper_tags_schema_version`
Expected: FAIL — `wire_reset` not defined.
- [ ] **Step 4: Add the `wire_reset` helper**
Append in `crates/kebab-cli/src/wire.rs` near the other `wire_*` helpers (above the `#[cfg(test)]` block):
```rust
/// Wrap a [`ResetReport`] as `reset_report.v1`.
pub fn wire_reset(r: &kebab_app::ResetReport) -> Value {
let v = serde_json::to_value(r).expect("ResetReport serializes");
tag_object(v, "reset_report.v1")
}
```
Also extend the existing `use` line at the top to import `ResetReport` if not already pulled in via the `kebab_app::` prefix used in the helper. (The above writes `kebab_app::ResetReport` inline, so no `use` change is strictly required.)
- [ ] **Step 5: Run tests to verify**
Run: `cargo test -p kebab-cli wire::`
Expected: 5 PASS — original 4 + new `reset_wrapper_tags_schema_version`.
- [ ] **Step 6: Commit**
```bash
git add docs/wire-schema/v1/reset_report.schema.json crates/kebab-cli/src/wire.rs
git commit -m "feat(cli/wire): add reset_report.v1 schema + wire_reset helper
JSON Schema 7 frozen surface for \`kebab reset --json\`. Mirrors the
ResetReport struct from kebab-app. p9-fb-06 task 3."
```
---
## Task 4: CLI `Cmd::Reset` + confirm prompt + integration test
**Files:**
- Modify: `crates/kebab-cli/src/main.rs` (add `Cmd::Reset` variant + handler + 20-line `confirm_destructive` helper)
- Create: `crates/kebab-cli/tests/reset_cli.rs`
- [ ] **Step 1: Write the failing integration test**
Create `crates/kebab-cli/tests/reset_cli.rs`:
```rust
//! Integration coverage for `kebab reset` — exercises the binary end-to-end
//! against a tempdir-rooted XDG layout.
use std::process::Command;
fn kebab_bin() -> std::path::PathBuf {
// Mirror the convention used by other tests in this crate (e.g.
// smoke tests under tests/). The compiled bin is at
// `target/debug/kebab` relative to the workspace root.
let manifest = env!("CARGO_MANIFEST_DIR");
std::path::PathBuf::from(manifest)
.parent()
.unwrap()
.parent()
.unwrap()
.join("target/debug/kebab")
}
#[test]
fn reset_data_only_yes_removes_data_dir_and_keeps_config() {
let tmp = tempfile::tempdir().unwrap();
let xdg_cfg = tmp.path().join("cfg");
let xdg_data = tmp.path().join("data");
let xdg_cache = tmp.path().join("cache");
let xdg_state = tmp.path().join("state");
std::fs::create_dir_all(xdg_cfg.join("kebab")).unwrap();
std::fs::create_dir_all(xdg_data.join("kebab")).unwrap();
std::fs::create_dir_all(xdg_cache.join("kebab")).unwrap();
std::fs::create_dir_all(xdg_state.join("kebab")).unwrap();
std::fs::write(xdg_cfg.join("kebab/config.toml"), "schema_version = 1\n").unwrap();
std::fs::write(xdg_data.join("kebab/marker"), b"data").unwrap();
let out = Command::new(kebab_bin())
.args(["reset", "--data-only", "--yes"])
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CACHE_HOME", &xdg_cache)
.env("XDG_STATE_HOME", &xdg_state)
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!xdg_data.join("kebab").exists(), "data dir should be gone");
assert!(xdg_cfg.join("kebab/config.toml").exists(), "config preserved");
}
#[test]
fn reset_no_yes_in_non_tty_aborts_with_exit_2() {
let tmp = tempfile::tempdir().unwrap();
let xdg_data = tmp.path().join("data");
std::fs::create_dir_all(xdg_data.join("kebab")).unwrap();
std::fs::write(xdg_data.join("kebab/marker"), b"d").unwrap();
let out = Command::new(kebab_bin())
.args(["reset", "--data-only"])
.env("XDG_CONFIG_HOME", tmp.path().join("cfg"))
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CACHE_HOME", tmp.path().join("cache"))
.env("XDG_STATE_HOME", tmp.path().join("state"))
.output()
.unwrap();
// Non-TTY (Command::output gives no tty) without --yes must abort.
assert!(!out.status.success(), "expected abort, got success");
let code = out.status.code().unwrap_or(-1);
assert_eq!(code, 2, "expected exit 2 (generic error), got {code}");
assert!(
xdg_data.join("kebab").exists(),
"data dir must survive an aborted reset"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("non-interactive") || stderr.contains("--yes"),
"expected refusal hint in stderr, got: {stderr}"
);
}
#[test]
fn reset_data_only_yes_json_emits_reset_report_v1() {
let tmp = tempfile::tempdir().unwrap();
let xdg_data = tmp.path().join("data");
std::fs::create_dir_all(xdg_data.join("kebab")).unwrap();
std::fs::write(xdg_data.join("kebab/marker"), b"d").unwrap();
let out = Command::new(kebab_bin())
.args(["--json", "reset", "--data-only", "--yes"])
.env("XDG_CONFIG_HOME", tmp.path().join("cfg"))
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CACHE_HOME", tmp.path().join("cache"))
.env("XDG_STATE_HOME", tmp.path().join("state"))
.output()
.unwrap();
assert!(out.status.success());
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("reset_report.v1"));
assert_eq!(v.get("scope").and_then(|s| s.as_str()), Some("data_only"));
assert!(v.get("removed_paths").and_then(|a| a.as_array()).is_some());
}
```
Add to `crates/kebab-cli/Cargo.toml`'s `[dev-dependencies]` if missing:
```toml
[dev-dependencies]
tempfile = "3"
serde_json = "1"
```
(Run `grep -n "tempfile\|serde_json" crates/kebab-cli/Cargo.toml` first — likely both already present from existing tests. Only add what's missing.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p kebab-cli --test reset_cli`
Expected: FAIL — `kebab reset` is not a recognized clap subcommand yet (the binary will print "error: unrecognized subcommand" and exit nonzero with the wrong error shape).
- [ ] **Step 3: Add the `Reset` clap variant**
In `crates/kebab-cli/src/main.rs`, inside `enum Cmd` (above `Doctor`):
```rust
/// Wipe XDG data dirs (and optionally the Lance vector store) so
/// the workspace can be re-initialised. **Irreversible.** Without
/// `--yes`, prompts on TTY; aborts in non-interactive contexts.
Reset {
/// Wipe config + data + cache + state. Implies losing
/// `config.toml` — re-run `kebab init` afterwards.
#[arg(long, group = "reset_scope")]
all: bool,
/// Default. Wipe data + cache + state. Config is preserved.
#[arg(long, group = "reset_scope")]
data_only: bool,
/// Wipe only the Lance vector store + truncate
/// `embedding_records`. SQLite documents / chunks survive so the
/// next `kebab ingest` re-embeds without re-parsing.
#[arg(long, group = "reset_scope")]
vector_only: bool,
/// Wipe only the config dir.
#[arg(long, group = "reset_scope")]
config_only: bool,
/// Skip the interactive confirm. Required in non-interactive
/// contexts (CI, pipes).
#[arg(long)]
yes: bool,
},
```
clap's `group = "reset_scope"` makes the four flags mutually exclusive automatically.
- [ ] **Step 4: Add the handler**
In `fn run(cli: &Cli) -> anyhow::Result<()>`, just above the `Cmd::Doctor =>` arm, insert:
```rust
Cmd::Reset {
all,
data_only,
vector_only,
config_only,
yes,
} => {
use kebab_app::ResetScope;
let scope = if *all {
ResetScope::All
} else if *vector_only {
ResetScope::VectorOnly
} else if *config_only {
ResetScope::ConfigOnly
} else {
// `--data-only` explicit OR no scope flag at all → DataOnly
let _ = data_only;
ResetScope::DataOnly
};
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let paths = kebab_app::reset::enumerate_paths(scope, &cfg);
let bytes = kebab_app::reset::estimate_size_bytes(&paths);
if !*yes {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"reset is destructive and stdin is non-interactive — pass --yes to proceed"
);
}
if !confirm_destructive(scope, &paths, bytes)? {
eprintln!("aborted.");
return Ok(());
}
}
let report = kebab_app::reset::execute(scope, &cfg)?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_reset(&report))?);
} else {
println!(
"removed {} path(s); embedding_rows_truncated={}",
report.removed_paths.len(),
report.embedding_rows_truncated
);
for p in &report.removed_paths {
println!(" - {}", p.display());
}
if matches!(scope, ResetScope::All | ResetScope::ConfigOnly) {
println!("hint: run `kebab init` to recreate config.toml");
}
}
Ok(())
}
```
- [ ] **Step 5: Add the `confirm_destructive` helper**
At the bottom of `crates/kebab-cli/src/main.rs` (after `fn run`):
```rust
/// Minimal stdin/stdout confirm prompt. No new dep — uses stdlib
/// `IsTerminal`. Returns `Ok(true)` only when the user types y/Y/yes.
/// Empty input or anything else → false (safe default).
fn confirm_destructive(
scope: kebab_app::ResetScope,
paths: &[std::path::PathBuf],
bytes: u64,
) -> anyhow::Result<bool> {
use std::io::Write;
let mut out = std::io::stderr().lock();
writeln!(out, "kebab reset ({:?}): about to remove", scope)?;
for p in paths {
writeln!(out, " - {}", p.display())?;
}
writeln!(out, "estimated total: {} bytes", bytes)?;
write!(out, "Proceed? [y/N] ")?;
out.flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let s = line.trim().to_ascii_lowercase();
Ok(matches!(s.as_str(), "y" | "yes"))
}
```
- [ ] **Step 6: Build the binary so the integration test can find it**
Run: `cargo build -p kebab-cli`
Expected: clean compile.
- [ ] **Step 7: Run integration tests**
Run: `cargo test -p kebab-cli --test reset_cli`
Expected: 3 PASS.
- [ ] **Step 8: Run the full crate test suite to confirm no regression**
Run: `cargo test -p kebab-cli`
Expected: full PASS.
- [ ] **Step 9: Commit**
```bash
git add crates/kebab-cli/src/main.rs crates/kebab-cli/tests/reset_cli.rs \
crates/kebab-cli/Cargo.toml
git commit -m "feat(cli): add \`kebab reset\` command with TTY confirm gate
Mutually-exclusive scope flags (--all / --data-only / --vector-only /
--config-only) plus --yes for non-interactive use. Aborts with exit 2
when stdin is non-interactive and --yes is missing — silent destruction
is forbidden. p9-fb-06 task 4."
```
---
## Task 5: README + HANDOFF.md sync (3-doc rule)
**Files:**
- Modify: `README.md` (명령 표 + Quick start safety note)
- Modify: `HANDOFF.md` (one-line note under deviations / new features)
`docs/ARCHITECTURE.md` is NOT touched — `kebab reset` doesn't change the crate graph or any locked-in technical decision.
- [ ] **Step 1: Add the row to the README 명령 table**
Open `README.md`, find the existing 명령 table (rows starting with `kebab init`, `kebab ingest`, etc.), and insert before `kebab eval`:
```markdown
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 같이 truncate (orphan 방지) |
```
- [ ] **Step 2: Add a safety note in the install / cleanup section**
In `README.md`, find the existing "제거" / `cargo uninstall kebab-cli` paragraph. Replace the manual `rm -rf` instruction with:
```markdown
제거는 `cargo uninstall kebab-cli`. 이 명령은 binary 만 지우고 워크스페이스 데이터는 그대로 남는다. 데이터까지 정리하려면 `kebab reset --all --yes` (config + data + cache + state 모두 wipe — 재시작 시 `kebab init` 다시 실행).
```
- [ ] **Step 3: Add a HANDOFF.md entry under "최근 발견 / 결정"**
Open `HANDOFF.md`, find the "머지 후 발견된 버그 / 결정 (요약)" section (or the equivalent "최근 변경" / dated bullet list). Add at the top of the most recent dated subsection:
```markdown
- 2026-05-02 P9 도그푸딩 후속 — `kebab reset --all|--data-only|--vector-only|--config-only [--yes]` 추가. TTY 가 아니면 `--yes` 필수 (silent destruction 금지). p9-fb-06 spec 참조.
```
(If HANDOFF doesn't already have a 2026-05-02 dated subsection, just add the bullet under whatever the latest section is — the date in the bullet itself is the source of truth.)
- [ ] **Step 4: Verify the docs render**
Run: `grep -n "kebab reset" README.md HANDOFF.md`
Expected: 명령 table row + cleanup paragraph + HANDOFF bullet (3 hits).
- [ ] **Step 5: Commit**
```bash
git add README.md HANDOFF.md
git commit -m "docs: \`kebab reset\` in README 명령 table + HANDOFF entry
3-doc sync rule: user-visible CLI surface change → README and HANDOFF
get the same PR. ARCHITECTURE.md unchanged (no crate graph or locked
decision moved). p9-fb-06 task 5."
```
---
## Task 6: Mark task spec status + verify full workspace
**Files:**
- Modify: `tasks/p9/p9-fb-06-data-reset-command.md` (frontmatter `status: planned``status: in_progress` then bump to `completed` after the PR merges; this task only flips to `in_progress`)
- [ ] **Step 1: Flip task status**
Edit `tasks/p9/p9-fb-06-data-reset-command.md` frontmatter:
```yaml
status: in_progress
```
(Final flip to `completed` happens in a separate one-line commit AFTER the PR merges, so the spec history reflects reality.)
- [ ] **Step 2: Run a wider test to confirm nothing else broke**
Run: `cargo test -p kebab-store-sqlite -p kebab-app -p kebab-cli`
Expected: full PASS across the three touched crates.
- [ ] **Step 3: Run clippy on the touched crates**
Run: `cargo clippy -p kebab-store-sqlite -p kebab-app -p kebab-cli --all-targets -- -D warnings`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add tasks/p9/p9-fb-06-data-reset-command.md
git commit -m "chore(tasks): mark p9-fb-06 in_progress
Flips to \`completed\` once the PR merges (separate one-line commit so
spec history reflects reality)."
```
- [ ] **Step 5: PR with gitea-ops review loop**
Per the project workflow rule (memory: `feedback_pr_workflow.md`):
1. `gitea-pr --title "feat(cli): kebab reset (p9-fb-06)" --head feat/p9-fb-06-reset --body <see below>`
2. `gitea-pr-status <PR#> --wait-ci` until gate passes.
3. Review loop: `gitea-pr-diff` → analyze → `gitea-pr-review` (REQUEST_CHANGES → ... → APPROVE).
4. **APPROVE achieved → merge immediately, no asking.** `tea pr merge <PR#>` (or Gitea API), pull main locally, delete branch, move on to the next task in the priority list (p9-fb-01 batch).
PR body template:
```markdown
## Summary
- `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]`.
- TTY confirm prompt (stdin non-interactive without `--yes` → exit 2).
- `--vector-only` truncates SQLite `embedding_records` to keep the store consistent after the off-disk Lance dir is removed.
- Wire schema `reset_report.v1` for `--json` mode.
## Scope (p9-fb-06)
- spec: `tasks/p9/p9-fb-06-data-reset-command.md`
- feedback origin: `tasks/p9/p9-dogfooding-feedback.md` item 4
## Test plan
- [x] `cargo test -p kebab-store-sqlite --test truncate_embeddings`
- [x] `cargo test -p kebab-app reset`
- [x] `cargo test -p kebab-cli --test reset_cli`
- [x] `cargo clippy --all-targets -- -D warnings` (touched crates)
```
---
## Self-review
**Spec coverage** (against `tasks/p9/p9-fb-06-data-reset-command.md`):
- Public surface `kebab reset [--all|--data-only|--vector-only|--config-only] [--yes]` → Task 4.
- Default = `--data-only` → Task 4 handler (`else { ResetScope::DataOnly }`).
- `--config <path>` honored → Task 4 (`Config::load(cli.config.as_deref())`). The behavior here matters more for `--vector-only` (which reads `cfg.storage.vector_dir`). For the XDG-rooted scopes, the path resolution goes through `Config::xdg_*` env-aware methods — same effective honor since the user can set `XDG_*_HOME` in the same shell.
- Confirm prompt with paths + bytes + `(y/N)` → Task 4 `confirm_destructive`.
- Non-TTY without `--yes` → abort with hint → Task 4 handler + integration test 2.
- `--vector-only` truncates `embedding_records` → Task 1 + Task 2.
- No auto `kebab init` → Task 4 handler emits a hint instead.
- Test plan items (path estimation unit / data-only integration / vector-only integration) → Tasks 2 + 4.
- DoD: `cargo test -p kebab-cli` PASS → Task 6. README updated → Task 5. `--help` "irreversible" → Task 4 doc-comment on the `Reset` variant.
**One spec note we did NOT implement separately**: `vector-only` integration test — Task 4 covers `data-only` + `non-tty` + `json`. The `vector-only` path is exercised by Task 2's `truncate_embeddings` unit test (does the wipe work?) and by the `enumerate_vector_only_returns_resolved_vector_dir` unit test (does enumeration return the right dir?). A full end-to-end `vector-only` integration test would require seeding both a Lance dir and embedding rows under a tempdir XDG layout — feasible but long, and the unit-level coverage already proves both halves. If the reviewer pushes back, add it as a follow-up commit in the same PR.
**Placeholder scan**: none — every step has the actual code or command.
**Type consistency**: `ResetScope` / `ResetReport` defined in Task 2, used unchanged in Tasks 3, 4. `truncate_embedding_records` returns `Result<()>` in Task 1, called in Task 2 via the wrapper that adds row count. Names match across tasks.
---
## Execution Handoff
Plan saved to `docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md`.
**Auto mode active + caveman feedback rule**: proceed with **inline execution** (executing-plans skill), no need to ask. Reasoning:
- 6 tasks, all touch crates I already know.
- Each task has tests that run in seconds — feedback loop tight enough that a fresh subagent per task would be overhead.
- PR merge follows immediately after task 6 per the updated workflow rule (no human gate between APPROVE and merge).
Starting executing-plans next.

View File

@@ -77,12 +77,33 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
- P8 — [p8/](p8/) — 2 components
- [p8-1 whisper-adapter](p8/p8-1-whisper-adapter.md)
- [p8-2 segment-chunker](p8/p8-2-segment-chunker.md)
- P9 — [p9/](p9/) — 5 components
- P9 — [p9/](p9/) — 5 components + 도그푸딩 피드백
- [p9-1 tui-library](p9/p9-1-tui-library.md)
- [p9-2 tui-search](p9/p9-2-tui-search.md)
- [p9-3 tui-ask](p9/p9-3-tui-ask.md)
- [p9-4 tui-inspect](p9/p9-4-tui-inspect.md)
- [p9-5 desktop-tauri](p9/p9-5-desktop-tauri.md)
- [p9-dogfooding 피드백 인덱스](p9/p9-dogfooding-feedback.md) — 사용자가 직접 돌려보며 수집한 UX 잡음 → p9-fb-01 ~ 20 으로 분해 완료
- [p9-fb-01 ingest progress callback](p9/p9-fb-01-ingest-progress-callback.md)
- [p9-fb-02 CLI progress display](p9/p9-fb-02-cli-progress-display.md)
- [p9-fb-03 TUI ingest background](p9/p9-fb-03-tui-ingest-background.md)
- [p9-fb-04 ingest cancellation](p9/p9-fb-04-ingest-cancellation.md)
- [p9-fb-05 config path policy](p9/p9-fb-05-config-path-policy.md)
- [p9-fb-06 kebab reset](p9/p9-fb-06-data-reset-command.md)
- [p9-fb-07 MD title fallback](p9/p9-fb-07-md-title-fallback.md)
- [p9-fb-08 search debounce](p9/p9-fb-08-search-debounce.md)
- [p9-fb-09 editor restore](p9/p9-fb-09-tui-editor-restore.md)
- [p9-fb-10 CJK input](p9/p9-fb-10-tui-cjk-input.md)
- [p9-fb-11 ask markdown render](p9/p9-fb-11-ask-markdown-render.md)
- [p9-fb-12 mode machine](p9/p9-fb-12-tui-mode-machine.md)
- [p9-fb-13 cheatsheet](p9/p9-fb-13-tui-cheatsheet.md)
- [p9-fb-14 color theme](p9/p9-fb-14-tui-color-theme.md)
- [p9-fb-15 RAG multi-turn core](p9/p9-fb-15-rag-multi-turn-core.md)
- [p9-fb-16 TUI ask conversation](p9/p9-fb-16-tui-ask-conversation.md)
- [p9-fb-17 chat session storage (V004)](p9/p9-fb-17-chat-session-storage.md)
- [p9-fb-18 CLI ask session/repl](p9/p9-fb-18-cli-ask-session-repl.md)
- [p9-fb-19 search cache](p9/p9-fb-19-search-cache.md)
- [p9-fb-20 citation surface](p9/p9-fb-20-citation-surface.md)
## Post-merge 핫픽스

View File

@@ -0,0 +1,334 @@
---
phase: P9
component: tui + cli + app
task_id: p9-dogfooding
title: "도그푸딩 피드백 — UX 개선 잡음 수집"
status: open
depends_on: [p9-1, p9-2, p9-3, p9-4]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7, §10]
---
# p9-dogfooding — 도그푸딩 피드백
P9-1 ~ P9-4 머지 + `cargo install --path crates/kebab-cli --locked` 후 사용자가 직접 `~/KnowledgeBase` 에서 ingest / search / ask / inspect 돌려보며 발견한 UX 잡음.
각 항목은 후속 task spec 으로 분리될 수 있는 단위로 정리. 머지 전 frozen 설계 §10 (UX) 와 충돌 시 spec 갱신 필요한지 표기.
## 발견 일자
2026-05-02 — PR #47 (gemma4 default + tilde fix) 머지 직후. 환경: Ubuntu, kebab 0.1.0, ratatui 0.28, gemma4:e4b on remote Ollama (192.168.0.47).
## 항목
### 1. 장시간 작업 진행 표시 (CLI / TUI / Desktop 공통)
ingest / embed / vector index build / RAG (긴 답변 streaming) 같이 초 단위 이상 걸리는 모든 작업에서 현재 진행 상황을 사용자에게 보여줘야 함.
**증상**: 사용자가 `kebab ingest` 돌렸는데 1.8 초 동안 "묵묵부답" 후 `scanned 0 new 0 ...` 만 표시. 사용자는 스캔 0 건 vs hung process 구분 불가.
**요구**:
- CLI: indeterminate spinner + "scanning <N> files / parsing <path> / embedding chunk <i>/<n>" 식 단계 표시. `--json` 모드면 line-delimited progress event (`schema_version=ingest_progress.v1`).
- TUI: Library / Status bar 에 progress 라인. 현재 ingest 는 background 가 아니라 blocking — 우선 background runner 도입 필요.
- Desktop (P9-5): 동일.
**spec 영향**: 설계 §7 ingest pipeline + §10 UX 에 progress event 명시 필요. wire schema `ingest_progress.v1` 추가.
### 2. 장시간 작업 즉시 중단 (Ctrl-C / Esc)
ingest / embed / RAG streaming 모두 사용자가 언제든 중단 가능해야 함. resume 가능하면 좋고, 불가능하면 처음부터 다시 라도 OK — 핵심은 **즉시 응답**.
**증상**: 현재 `kebab ingest` 는 SIGINT 받으면 단순 종료 — 마지막 commit 까지의 SQLite row 는 살아있어 다음 ingest 가 idempotent 하게 동작하긴 함. 다만 진행 중 chunk 임베딩이 long-running 이면 Ctrl-C 응답이 느림.
**요구**:
- Cooperative cancellation token (`std::sync::atomic::AtomicBool` 또는 `tokio_util::sync::CancellationToken`) 을 ingest 루프 내부 step boundary 에 check.
- 부분 진행 commit-as-you-go 는 현재 동작 유지 (SQLite per-asset transaction).
- TUI: Esc / Ctrl-C 두 키 모두 cancel 신호 보내고 worker thread 가 step boundary 에 도달하면 종료.
**spec 영향**: §7 의 ingest 결정성 절은 유지 — partial 결과는 valid 한 prefix.
### 3. workspace.root 상대 경로 + init placeholder 명확화
`config.toml``workspace.root` 가 상대경로면 어디 기준인지 모호. 절대경로만 강제하거나, 기준 디렉토리 명시.
**증상**: 사용자가 `~/KnowledgeBase` (tilde) 로 두고 ingest 했을 때 expand_tilde 가 적용된 후 정상 동작했지만, 이전에는 절대경로로 변경해야만 동작했음 (PR #47 fix 전). 도그푸딩 사용자는 처음에 무엇이 root 인지 헷갈렸음.
**요구**:
- 가능하면 상대경로도 허용 — base 는 `current_working_dir` 또는 `config 파일 dir` 중 무엇? 후자 선호 (config 따라다님).
- 불가능하면 `kebab init` 가 생성하는 placeholder 를 절대경로로 (예: `/home/<user>/KnowledgeBase` 가 아니라 `~/KnowledgeBase` 권장 — tilde 는 expand 됨).
- README 의 Quick start 에서 `${EDITOR} ~/.config/kebab/config.toml` 후 해야 할 일 절대경로로 안내. 현재는 "absolute path 로 변경" 만 막연.
- config.toml 코멘트 한 줄 추가: `# absolute path or ~/...; relative path is currently NOT supported`.
**spec 영향**: §6.2 (workspace 정의) 에 명시 추가.
### 4. 전체 데이터 삭제 명령
사용자가 여러 config / 모델 조합 테스트 중 — `kebab` 데이터를 깔끔히 초기화하는 단일 명령 필요.
**증상**: 현재 `cargo uninstall kebab-cli` 만 하고 데이터는 수동 `rm -rf ~/.local/share/kebab ~/.config/kebab ~/.cache/kebab ~/.local/state/kebab`. 4 개 경로 외우기 부담.
**요구**:
- `kebab nuke` (또는 `kebab reset --all`) — XDG 경로 4 개 + binary 는 보존, 데이터만 wipe. 첫 실행 confirm prompt + `--yes` flag.
- `kebab reset --data-only` (sqlite + lance 만, config 보존), `kebab reset --vector-only` 등 부분 wipe variant.
- `--config <path>` 도 honor — isolated workspace wipe.
**spec 영향**: §10 UX 에 reset/nuke 명령 추가.
### 5. inspect 화면의 title 공백 (도그푸딩 corpus)
`~/KnowledgeBase/testdata/coding-md-corpus/python/python-360-annotation-issues-at-runtime.md` 같은 파일들 ingest 후 `kebab tui` Library + Inspect 에서 모든 doc 의 title 이 공백.
**증상**: title 추출 로직이 frontmatter `title:` 또는 첫 H1 (`# Title`) 둘 중 하나에 의존. corpus md 가 frontmatter 없고 H1 없이 H2 부터 시작하면 title=빈 문자열.
**요구**:
- title fallback 우선순위 명시: (1) frontmatter `title` → (2) 첫 H1 → (3) 첫 H2 → (4) 첫 non-empty paragraph 의 첫 N 자 → (5) 파일명 (확장자 제외).
- 현재 로직 `kebab-parse-md/src/...` 확인 후 수정. parser_version cascade — bump 필요.
**spec 영향**: §3.5 / §5.5 normalize 에 title fallback 명시. parser_version `md-frontmatter-v1``md-frontmatter-v2`.
### 6. search 의 keystroke-by-keystroke 지연
TUI search pane 에서 한 글자 칠 때마다 검색 호출 → SQLite FTS + Lance vector + RRF 모두 hot path 라 체감 지연.
**증상**: 사용자가 문장 칠 때 키 입력 직후 frame 잠김. backspace 도 마찬가지.
**요구**:
- 옵션 A: debounce — 마지막 keystroke 후 N ms (예: 250ms) 무입력이면 검색 trigger.
- 옵션 B: Enter 만 trigger — 단순. 사용자도 "OK" 라고 말함.
- 옵션 A + B 결합: debounce default + Enter 즉시 trigger override.
**구현**: search worker thread 에 `latest_query: Mutex<String>` + `debounce_at: Instant` — main loop tick 마다 check. 또는 mpsc channel 로 `Query(s, generation)` 보내고 worker 가 `generation` 비교해 stale 결과 drop.
**spec 영향**: 없음 — 구현 detail.
### 7. search → editor (`g` 키) → `:q` 복귀 후 화면 깨짐
search pane 에서 `g` 로 vim/code 띄우고 종료 후 TUI 화면이 일부만 redraw. 입력 한 글자가 바뀐 부분만 다시 그려지고 나머지는 invisible.
**증상**: external editor exit 후 ratatui 의 alternate screen + mouse capture restore 누락. crossterm 의 `disable_raw_mode` / `LeaveAlternateScreen` 을 editor spawn 시 해주고 복귀 후 `EnterAlternateScreen` + `enable_raw_mode` 다시 호출 필요.
**요구**:
- `kebab-tui::search::open_in_editor` 직전 `Terminal::leave_alternate_screen` + `disable_raw_mode`, child 종료 후 `Terminal::clear()` + `enter_alternate_screen` + `enable_raw_mode` + 다음 frame 강제 redraw (`force_redraw: bool` flag).
- 이 패턴은 inspect / library 의 file open 도 동일 — 공통 helper.
**spec 영향**: 없음 — 구현 detail.
### 8. 한글 입력 (IME / multi-byte)
전반적으로 한글로 검색 / 질문 칠 텐데 한글 입력이 의도한 곳과 다른 곳에 들어감.
**증상**: 추측 — IME composing 중간 글자가 search input 이 아닌 다른 키 (e/j/k 등 single-char command) 로 라우팅되거나, multi-byte buffer 가 잘려 wide-char 깨짐.
**요구**:
- 한글 (CJK) IME 입력 흐름 정리. crossterm 은 IME composing event 를 native 로 surface 안 함 — `KeyCode::Char(c)` 로 자모 단위 도착. 입력 mode 일 때는 모든 `Char` 가 buffer push, command mode 가 따로 있어야 e/j/k/g 같은 키가 의미를 가짐 (10번 항목과 묶음).
- 한글 wide-char rendering: 현재 `unicode-width::UnicodeWidthStr` 사용 중인지 확인. width 계산 누락이면 cursor 위치 어긋남.
- 테스트 fixture: 한글 query (`러스트 비동기`), 한글 답변 streaming, 한글 파일명 (`테스트.md`).
**spec 영향**: 없음 — UX detail.
### 9. ask 답변의 markdown 렌더링
LLM 답변이 markdown (bold, italic, table, code block, list) 일 때 raw `**bold**` / `| col |` 그대로 출력. ratatui 에 markdown 렌더링 없음.
**요구**:
- ratatui 의 `Span` / `Line` styling 으로 inline 변환: bold (`**text**`) → `Style::default().add_modifier(Modifier::BOLD)`, italic (`*text*` / `_text_`) → `ITALIC`, inline code (`` `code` ``) → `Style::default().bg(Color::DarkGray)`.
- block 단위: heading (#) → 큰 fg color, list bullet, code fence (```) → 박스 + monospace, table → ratatui `Table` widget.
- 파서: `pulldown-cmark` 이미 workspace 에 있음 — 재사용.
- 답변 streaming 중에는 incremental render — 마지막 incomplete inline span 만 raw 로 출력하고 complete span 부터 styled.
**spec 영향**: §7 의 RAG output 절에 markdown rendering hint.
### 10. 입력 mode vs command mode (search / ask)
search / ask 의 query input box 에 모든 키가 입력으로 들어가 e=explain 같은 single-char command 사용 불가. 현재 P9-3 ask 가 input-empty 일 때만 e/j/k 를 command 로 인식 (heuristic) — 사용자는 명시적 mode 선호.
**요구**:
- vim 식 `i` (insert) / `Esc` (normal/command) — TUI 전반에 통일. status bar 에 현재 mode 표시 (`-- INSERT --` / `-- NORMAL --`).
- 또는 input box 가 focused 가 아닐 때 keys 가 command 로 (Tab/Shift-Tab 으로 focus 이동). focus indicator (테두리 색상) 명확히.
- 첫 옵션 선호 — vim 친숙도 + 모드 전환이 명시적.
**spec 영향**: §10 UX 에 mode 모델 명시 필요.
### 11. 조작법 친절 안내 (vim 비익숙 사용자)
현재 TUI 하단 hint 라인이 단순 키 나열. vim 비익숙 사용자에게는 의미 불투명.
**요구**:
- 각 pane 마다 키 매핑 cheatsheet — `?` 누르면 modal popup 으로 전체 키 목록 + 짧은 설명.
- hint line 자체도 명사구가 아니라 동사구 ("k=up" 보다 "↑/k 위로 이동").
- mode 별 hint 분기 (10번과 결합): NORMAL 은 navigation 키, INSERT 는 "Esc 로 normal" 만.
- README 에도 TUI 키 cheatsheet 표 추가.
**spec 영향**: §10 UX 추가.
### 12. TUI 색상 팔레트 확장
현재 TUI 가 거의 monochrome — 테두리 / 강조 1~2 색 정도. 사용자가 "더 많은 색깔의 글씨" 원함.
**증상**: Library / Search / Ask / Inspect 모든 pane 이 default fg + 1~2 accent. 정보 종류 (title vs path vs score vs warning vs streaming token) 가 색으로 구분 안 됨.
**요구**:
- 의미 단위 color role 정의 (단순 색 채우기 X — 의미 매핑):
- `title` (Doc / Section heading) → Cyan / bright
- `path` (workspace_path) → DarkGray / Dim
- `score` (search hit RRF / relevance) → Green (high) → Yellow → Red (low) gradient
- `mode` (lexical / vector / hybrid) → 각각 다른 hue
- `warning` / `error` → Yellow / Red bg
- `streaming` token (ask 답변 새로 도착한 부분) → 옅은 Blue background, 1초 후 default 로 fade
- `citation` source span → underline + Magenta
- `keyword highlight` (search query 매치 부분) → Yellow bg
- ratatui `Style` + `Color::Indexed`/`Color::Rgb` 활용. 256-color terminal 가정.
- color theme module — 한 곳 (`kebab-tui::theme`) 에 role → Style 매핑. light/dark theme 토글 (`theme = "dark"` config 또는 `T` 키 cycle).
- accessibility — color 단독으로 의미 전달 X (color blind 고려). 색상은 보조, primary 정보는 텍스트 / 위치.
**spec 영향**: §10 UX 에 color role 명시 권장. 다만 구현 detail 에 가까움.
### 13. ask 멀티턴 (대화 history 컨텍스트 + scrollback UI)
현재 ask 는 질문 1 회 → 답변 1 회 단발. 사용자는 "꼬리 물기" 자연스러움 — 이전 질답을 컨텍스트로 LLM 에 같이 전달 + UI 도 history 보이는 conversation view 로 변경.
**증상**: "X 가 뭐야?" 답변 후 "그럼 Y 와는 어떤 차이?" 물으면 LLM 은 X 를 모름. retrieval 로 일부 회복되긴 하지만 사용자 의도 (= "방금 답한 X 와 비교") 는 사라짐.
**요구**:
- **컨텍스트 전달**: ask 세션 내부에 `Vec<Turn>` (`Turn { question, answer, citations, ts }`) 유지. LLM prompt 빌드 시 system + (history N turns) + retrieved chunks + 새 question. token budget (`rag.max_context_tokens`) 안에 fit — history 가 budget 침범하면 retrieval k 줄이거나 oldest turn drop. 정책 명시 필요.
- **retrieval 강화**: 새 question 단독 검색 X — 직전 answer + question 합쳐 query expansion (간단: concat. 고급: LLM 으로 standalone question rewriting). 우선 concat 으로 시작.
- **UI**:
- ask pane 을 conversation 형태로 — 위 scrollable transcript (Q1 / A1 / citations / Q2 / A2 / ...) + 아래 input box.
- Q 와 A 시각 구분 (12번 color role 활용 — Q=Cyan bold, A=default).
- citation 은 turn 별로 fold (`▸ 근거 N 건`). 펼침 키.
- PageUp/PageDown / k/j 로 scroll. 최신 답변 도착 시 자동 bottom.
- **세션 영속**: P+ 옵션 — 종료 시 SQLite `chat_sessions` / `chat_turns` 테이블에 저장. 다음 실행 시 "이전 대화 이어가기" 또는 "새 대화" 선택. 우선 in-memory 만.
- **세션 reset**: `Ctrl-L` 또는 `:new` 로 history clear (현재 ask pane 리셋 동등).
- **mode 전환** (10번과 결합): conversation scrollback 보면서 NORMAL 키 (j/k/PageUp) 동작, INSERT 진입 시 input box 만 영향.
**spec 영향**: §7 RAG 절에 multi-turn / conversation history 정책 추가. wire schema `answer.v1``conversation_id` / `turn_index` 옵션 필드. SQLite 새 테이블 (`chat_sessions`, `chat_turns`) — migration `V004`. token budget 정책 명시.
**구현 사이즈**: 큼. `kebab-rag` (history-aware prompt 빌드) + `kebab-store-sqlite` (V004 migration, 영속화하면) + `kebab-tui::ask` (conversation UI 전면 재작성) + `kebab-app` facade (`ask_with_session(...)`) + wire schema. 단발 기능 제거 X — `kebab ask` CLI 한 turn 모드는 유지, TUI 만 multi-turn (CLI multi-turn 은 14 번 참조).
### 14. ask CLI multi-turn (옵션)
13 번 conversation history 가 TUI 만이 아닌 CLI 도 동작하면 유용. 사용자가 명시적으로 enable.
**증상**: 현재 `kebab ask "Q"` 는 항상 1 회. shell history 로 재호출해도 LLM 은 직전 답변 모름.
**요구**:
- **옵션 A — 세션 ID 명시**: `kebab ask --session <id> "Q"`. 같은 `<id>` 로 호출 시 SQLite `chat_sessions` 에 누적. ID 미지정이면 단발 (현재 동작 유지). 13 번의 SQLite V004 와 공유.
- **옵션 B — REPL 모드**: `kebab ask --repl` 실행 시 stdin 로 prompt → answer 반복. Ctrl-D / `:q` 종료. session 은 in-memory (영속 원하면 `--session <id>` 결합).
- **옵션 C — auto-session**: 같은 TTY 에서 N 분 내 연속 호출이면 자동 묶음 (ad-hoc session). PID + tty + ts hash. 우선순위 낮음 — magic 하고 디버깅 어려움.
- 권장: A + B 조합. C 는 P+.
- `--json` 모드 호환: `answer.v1``conversation_id` / `turn_index` 필드 (13 번에서 추가) — 외부 도구가 session 추적 가능.
- **외부 AI 통합 효과** (README 의 외부 AI 섹션): Claude Code skill / MCP server 도 `--session` 으로 conversation context 보존. 이 부분이 multi-turn CLI 의 진짜 가치 — 내장 TUI 만 쓰는 사용자보다 외부 wrapper 사용자가 큼.
**spec 영향**: §7 RAG 절 multi-turn 정책 + §externalAI 통합 절 (READE 와 ARCHITECTURE 동기화) 에 session 모델 추가. CLI flag 표 (`--session` / `--repl`) README 갱신.
### 15. search 결과 캐싱 (incremental invalidation)
같은 query 반복 시 매번 SQLite FTS + Lance vector + RRF 재계산. cache 가능.
**증상**: 사용자가 search pane 에서 같은 query 로 이동하거나 (Library → Search → Library → Search), TUI 다른 pane 갔다 다시 와도 재검색. CLI 도 마찬가지.
**요구**:
- **cache key**: `(query_normalized, mode, k, snippet_chars, embedding_version, chunker_version, prompt_template_version=N/A)` tuple → `Vec<SearchHit>`.
- query 정규화: trim + NFKC + lowercase. 공백 / 대소문자 차이가 hit 동일하면 같은 entry.
- **cache 위치**:
- 옵션 A: in-memory LRU (TUI session 단위) — 단순, 빠름. TUI 가 session 시작 시 빈 cache.
- 옵션 B: SQLite `search_cache` 테이블 — process 간 공유, 영속. SELECT 기반이라 sub-ms.
- 옵션 A 우선 — 단순하고 도그푸딩 막힘 해결. B 는 P+ (CLI 호출 빈도 높을 때).
- **invalidation (incremental — 핵심 요청 부분)**: ingest 가 doc 추가/삭제하면 기존 cache entry 도 영향 받음. 두 전략:
- 전략 1 — **bump generation counter**: `index_version: u64` 단조 증가. ingest 가 1 chunk 라도 변경하면 +1. cache key 에 `index_version` 포함 → 모든 stale entry 자동 무효. 단순, 모든 cache hit 무효화 — 캐시 가치 적음.
- 전략 2 — **dirty doc set**: ingest 가 변경한 `Vec<doc_id>` 기록. cache lookup 시 entry 의 hits 가 dirty doc 포함하면 stale 처리. cache entry 보존율 ↑, 복잡도 ↑.
- 전략 3 — **patch-and-merge** (사용자가 말한 "추가되는 내용만 끼워넣음"): cache entry 보관, ingest 의 새 chunks 만 별도 검색 → 기존 결과와 RRF 재합성. lexical (FTS) 는 새 doc 만 매치 검사 + score 정규화 재계산. vector 는 새 doc 의 embedding 만 query vector 와 cos sim 계산. 정확하지만 RRF normalization (post-merge hotfix `2/(k+1)` 정규화 — frozen design 표) 가 전체 hit set 기준 재계산이라 incremental 어려움.
- 권장: 1 → 3 단계. 우선 1 (단순) 도입, 측정 후 3 도입 결정. 2 는 중간 단계라 skip.
- **TTL**: in-memory cache 는 process 수명. SQLite 영속 시 `created_at` + 1 일 TTL 또는 ingest 시 wipe.
- **CLI**: `kebab search --no-cache` 로 강제 bypass. 디버깅용.
**spec 영향**: cache 자체는 구현 detail. 다만 `index_version` (전략 1) 는 design §9 의 versioning cascade 에 새 차원 — 명시 필요. 사용자 주의: cache miss/hit 동작이 다르면 외부 도구 (skill/MCP) 가 timing 의존하면 안 됨 — 결과 자체는 동일 보장.
### 16. ask 답변의 citation 풀 경로 보기 + scroll
ask 답변 끝에 citation list 가 나오는데 path 가 truncated 또는 한 줄에 몰림. 사용자가 풀 경로 확인하려 함.
**증상**: 현재 ask 출력 (CLI 와 TUI 둘 다) citation 이 inline `[1] notes/foo.md#L12-L34` 형태로 narrow terminal 에서 잘림. TUI ask pane 은 답변 본문 + citation 같은 buffer — 별도 scroll 영역 없음.
**요구**:
- **CLI**:
- 답변 후 citation 절 — path 한 줄씩, full path (workspace_path + fragment). truncate 안 함.
- `--show-citations / --hide-citations` flag (default: show). `--json` 은 항상 포함.
- 형식: `[1] crates/kebab-app/src/lib.rs#L120-L140 (score=0.78, doc_id=abc123)`.
- **TUI ask pane**:
- layout 분리 — 위 conversation transcript (13 번), 아래 citation pane (toggle-able). citation pane 은 turn 별 인용 list, 각 항목 (`[1] full/path/here.md`, line range, score).
- citation pane 자체 scroll (`j/k` 또는 PageUp/PageDown).
- 항목 선택 + Enter 또는 `o` → P9-2 search 의 editor jump 와 동일 동작 (vim/code 로 path 열기 — line range 점프).
- 항목 선택 + `i` → P9-4 inspect 로 (Doc / Chunk).
- **layout 모드 토글**: `:cite` 또는 `c` 키로 citation pane fold/expand. 기본은 expanded.
**spec 영향**: §10 UX 의 ask 절에 citation surface 명시. wire schema `answer.v1``citations` 배열은 이미 존재 — UI 가 활용 못 했을 뿐.
## 후속 작업 분리 (분해 결과)
20 개 task spec 으로 분해 완료. 각 spec 은 single-PR 단위. 의존 관계는 frontmatter `depends_on` / `unblocks` 참조.
| task | 제목 | 의존 | feedback 항목 |
|------|------|------|---------------|
| [p9-fb-01](p9-fb-01-ingest-progress-callback.md) | Ingest progress callback | | 1 (backend) |
| [p9-fb-02](p9-fb-02-cli-progress-display.md) | CLI progress display | 01 | 1 (CLI) |
| [p9-fb-03](p9-fb-03-tui-ingest-background.md) | TUI ingest background + status | 01 | 1 (TUI) |
| [p9-fb-04](p9-fb-04-ingest-cancellation.md) | Cooperative cancellation | 01 | 2 |
| [p9-fb-05](p9-fb-05-config-path-policy.md) | workspace.root path policy | | 3 |
| [p9-fb-06](p9-fb-06-data-reset-command.md) | `kebab reset` 명령 | | 4 |
| [p9-fb-07](p9-fb-07-md-title-fallback.md) | MD title fallback chain | | 5 |
| [p9-fb-08](p9-fb-08-search-debounce.md) | Search debounce + Enter | | 6 |
| [p9-fb-09](p9-fb-09-tui-editor-restore.md) | External editor return restore | | 7 |
| [p9-fb-10](p9-fb-10-tui-cjk-input.md) | CJK input + wide-char | 12 | 8 |
| [p9-fb-11](p9-fb-11-ask-markdown-render.md) | Ask markdown render | 14 | 9 |
| [p9-fb-12](p9-fb-12-tui-mode-machine.md) | NORMAL / INSERT mode | | 10 |
| [p9-fb-13](p9-fb-13-tui-cheatsheet.md) | Cheatsheet popup + keymap | 12 | 11 |
| [p9-fb-14](p9-fb-14-tui-color-theme.md) | Color theme module | | 12 |
| [p9-fb-15](p9-fb-15-rag-multi-turn-core.md) | RAG history + token budget | | 13 (core) |
| [p9-fb-16](p9-fb-16-tui-ask-conversation.md) | TUI ask conversation UI | 15, 12 | 13 (UI) |
| [p9-fb-17](p9-fb-17-chat-session-storage.md) | SQLite V004 chat sessions | 15 | 13, 14 |
| [p9-fb-18](p9-fb-18-cli-ask-session-repl.md) | CLI `--session` / `--repl` | 15, 17 | 14 |
| [p9-fb-19](p9-fb-19-search-cache.md) | Search result LRU cache | | 15 |
| [p9-fb-20](p9-fb-20-citation-surface.md) | Citation full path + scroll | 09, 16 | 16 |
## 권장 실행 순서 (도그푸딩 막힘 강도)
1. **p9-fb-06** (reset) — 테스트 반복 시 막힘 가장 큼.
2. **p9-fb-01 / 02 / 03** (progress) — ingest hung vs empty 구분 못 함이 다음 큰 막힘.
3. **p9-fb-04** (cancel) — progress 와 같은 PR batch 가능.
4. **p9-fb-15 / 16** (multi-turn 핵심) — 사용자 의도 (꼬리 물기) 직결.
5. **p9-fb-20** (citation) — 출처 보기, 도그푸딩 후속 검증의 prerequisite.
6. **p9-fb-07** (title fallback) — Inspect 가 비어있는 문제. 작은 size.
7. **p9-fb-19** (search cache) — 5번 debounce 와 함께 응답성.
8. **p9-fb-08** (debounce) — 작은 size, 즉시 효과.
9. **p9-fb-09** (editor restore) — 작은 size.
10. **p9-fb-12 → 13** (mode → cheatsheet) — UX 일관성 묶음.
11. **p9-fb-14** (theme) — 12 와 같은 batch 가능, 11 prerequisite.
12. **p9-fb-11** (markdown render) — theme 위에 build.
13. **p9-fb-17** (V004 storage) — multi-turn 영속화 prerequisite for 18.
14. **p9-fb-18** (CLI session) — 외부 AI 통합 효과 큼.
15. **p9-fb-10** (CJK) — mode machine 후. 한글 사용자에게 큼.
16. **p9-fb-05** (path policy) — 절대 경로 우회 가능, 후순위.
## spec PR vs impl PR
frozen design §10 (UX) / §7 (RAG multi-turn) / §5 (storage chat tables) / §9 (versioning index_version 추가) 갱신 동반 task:
- p9-fb-01 (ingest_progress.v1 wire schema)
- p9-fb-06 (reset 명령)
- p9-fb-07 (parser_version cascade — 이미 §9 covered, 신규 spec 변경 없음)
- p9-fb-12 (mode machine)
- p9-fb-13 (cheatsheet)
- p9-fb-15 (RAG multi-turn 정책)
- p9-fb-17 (chat_sessions / chat_turns 테이블)
- p9-fb-18 (answer.v1 의 conversation_id / turn_index)
- p9-fb-19 (index_version cascade)
위 task 의 PR 직전 spec 갱신 필요. spec PR 한 번에 묶어 진행 권장 (frozen 설계 일관성).
## Risks / notes
- 항목 중 일부는 frozen design §10 UX 갱신 동반 (1, 4, 5, 10, 11). spec PR 먼저 → impl PR 분리.
- title fallback (5) 은 parser_version cascade — 기존 ingest 된 doc 재처리 필요. 사용자 데이터 wipe (4) 와 함께 진행하면 깔끔.
- 한글 + IME (8) 는 ratatui / crossterm 의 한계가 있을 수 있음 — 완전 해결 어려우면 `ja/ko/zh user 는 외부 editor 권장` fallback 안내.

View File

@@ -0,0 +1,78 @@
---
phase: P9
component: kebab-app + kebab-core
task_id: p9-fb-01
title: "Ingest progress callback / event channel"
status: planned
depends_on: []
unblocks: [p9-fb-02, p9-fb-03]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 ingest, §10 UX]
source_feedback: p9-dogfooding-feedback.md item 1
---
# p9-fb-01 — Ingest progress callback
## Goal
`kebab_app::ingest_with_config` 가 진행 상황을 caller 에게 흘려보낼 수 있도록 progress callback (또는 mpsc Sender) 주입 surface 추가. CLI / TUI / desktop 셋 모두 같은 이벤트 stream 소비.
## Why now
도그푸딩 시 ingest 가 1.8 초 묵음 후 결과만 출력 — hung 인지 빈 워크스페이스인지 구분 불가. progress event 가 모든 UI surface 의 prerequisite.
## Allowed dependencies
- 기존 kebab-app deps. 신규 X.
- `std::sync::mpsc` 또는 `crossbeam_channel`.
## Public surface
```rust
#[derive(Debug, Clone)]
pub enum IngestEvent {
ScanStarted { root: PathBuf },
ScanCompleted { total: u32 },
AssetStarted { idx: u32, total: u32, path: String, media: MediaKind },
AssetFinished { idx: u32, kind: IngestItemKind, chunks: u32 },
EmbedBatchStarted { n_chunks: u32 },
EmbedBatchFinished { n_chunks: u32, ms: u64 },
Aborted { partial_counts: AggregateCounts },
Completed { counts: AggregateCounts },
}
#[doc(hidden)]
pub fn ingest_with_config_progress(
config: kebab_config::Config,
scope: SourceScope,
summary_only: bool,
progress: Option<Sender<IngestEvent>>,
) -> anyhow::Result<IngestReport>;
```
기존 `ingest_with_config``progress=None` 으로 forwarding wrapper.
## Behavior contract
- progress event 발신은 best-effort. receiver drop 되면 이후 send 무시 (panic 금지).
- 이벤트 ordering: `ScanStarted < ScanCompleted < (AssetStarted < AssetFinished)* < Completed|Aborted`. embed batch 는 asset 사이 임의 위치.
- `Aborted` 는 cancellation token (p9-fb-04) trigger 시. 혼자 발생 X — 14 번과 wiring.
- `--json` CLI 는 line-delimited 형태로 dump (`schema_version=ingest_progress.v1`) — 별도 task (p9-fb-02).
## Test plan
| kind | description |
|------|-------------|
| unit | `Sender<IngestEvent>` 가 ScanStarted → ScanCompleted → Asset* → Completed 순서로 받는다 |
| integration | tmp workspace 3 md → 받은 이벤트 sequence 가 monotonic idx |
## DoD
- [ ] `cargo test -p kebab-app` 통과
- [ ] 기존 `ingest_with_config` 호출자 (CLI 단발 호출) 변경 없음
- [ ] HOTFIXES 항목 X — 신기능, deviation 아님
## Out of scope
- progress event JSON 직렬화 (별도 wire schema task)
- TUI 가 이벤트 소비해서 status bar 그리기 (p9-fb-03)

View File

@@ -0,0 +1,54 @@
---
phase: P9
component: kebab-cli
task_id: p9-fb-02
title: "CLI progress display (spinner + text + --json line events)"
status: planned
depends_on: [p9-fb-01]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 ingest, §10 UX]
source_feedback: p9-dogfooding-feedback.md item 1
---
# p9-fb-02 — CLI progress display
## Goal
`kebab ingest` (그리고 추후 `kebab eval run` 등 long-running 명령) 이 stderr 에 spinner + 진행 라인을 그리고, `--json` 모드면 stdout 에 line-delimited progress event 를 dump.
## Allowed dependencies
- `kebab-app` (progress event 소비)
- `indicatif = "0.17"` 또는 자체 minimal spinner (선호: indicatif — 검증된 라이브러리)
- `serde_json`
## Public surface
`kebab-cli` 내부 함수 — public API 변경 없음. progress receiver thread 가 event 받아 indicatif `ProgressBar` 갱신, `--json` 이면 별도로 stdout 한 줄.
## Behavior contract
- TTY 감지: `is_terminal()` (`std::io::IsTerminal`). non-TTY (CI / pipe) 에서는 spinner 끄고 매 N 초마다 한 줄 progress 출력.
- `--json` 은 spinner 끄고 line-delimited JSON 만. 마지막 줄은 기존 `ingest_report.v1` 그대로.
- progress JSON wire schema 는 새 `ingest_progress.v1``docs/wire-schema/v1/ingest_progress.schema.json` 추가.
- stderr 사용 (stdout 는 `--json` 결과만, redirection 깔끔).
## Test plan
| kind | description |
|------|-------------|
| unit | ProgressDisplay 가 IngestEvent stream → 사람-친화 텍스트 변환 (no panic) |
| snapshot | `--json` line stream 이 schema 에 validate |
| integration | `kebab ingest --json` non-TTY 에서 spinner 미출력 |
## DoD
- [ ] `cargo test -p kebab-cli` 통과
- [ ] 새 wire schema `ingest_progress.v1` JSON Schema 7 + 예시
- [ ] README **명령** 표 / Quick start 갱신 (spinner / `--json` 동작 명시)
## Out of scope
- `kebab eval run` 진행 표시 (이 task 의 surface 만 이식 가능하게 두고 별도 task)
- TUI 진행 표시 (p9-fb-03)

View File

@@ -0,0 +1,63 @@
---
phase: P9
component: kebab-tui
task_id: p9-fb-03
title: "TUI ingest as background worker + status bar"
status: planned
depends_on: [p9-fb-01]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 ingest, §10 UX]
source_feedback: p9-dogfooding-feedback.md item 1
---
# p9-fb-03 — TUI ingest background + status bar
## Goal
TUI 에서 `:ingest` (또는 `r` 키) 누르면 ingest 가 background thread 에서 돌고, status bar 가 progress 그리기. blocking 하지 않음.
## Allowed dependencies
- 기존 kebab-tui deps (ratatui, crossterm, kebab-app, kebab-config).
- 신규 X.
## Public surface
`kebab-tui::App``ingest_state: Option<IngestState>` slot. p9-3/4 와 동일 parallel-safe pattern.
```rust
pub(crate) struct IngestState {
rx: Receiver<IngestEvent>,
counts: AggregateCounts,
current_path: Option<String>,
started_at: Instant,
cancel_tx: Sender<()>, // p9-fb-04 와 wiring
}
```
## Behavior contract
- ingest worker thread 는 `kebab_app::ingest_with_config_progress(cfg, scope, false, Some(tx))` 호출.
- main loop 가 매 frame 마다 `rx.try_recv()` drain → counts 갱신 + status bar 라인 갱신.
- status bar 위치: 화면 하단 1 줄. 형식: `ingest: 142/1024 (14%) parsing notes/foo.md [0:42]`.
- 완료/abort 시 status bar 가 final line (`✓ ingest: 1024 docs, 4521 chunks, 12.3s` 또는 `✗ aborted at 142/1024`) 잠시 유지 후 자동 hide.
- ingest 중 다른 pane 이동 자유 — Library / Search 등은 그대로 동작 (DB 는 read 가능, partial 결과 surface).
## Test plan
| kind | description |
|------|-------------|
| unit | IngestState event drain 이 counts 누적 |
| integration | TUI run-loop 모의 + IngestEvent stream → status line 텍스트 snapshot |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] README TUI 절에 background ingest + status bar 동작 명시
- [ ] 키 cheatsheet 에 `r` (refresh/ingest) 추가
## Out of scope
- desktop (P9-5) progress 표시
- ingest cancel UI (p9-fb-04)

View File

@@ -0,0 +1,64 @@
---
phase: P9
component: kebab-app + kebab-cli + kebab-tui
task_id: p9-fb-04
title: "Cooperative ingest cancellation (Ctrl-C / Esc)"
status: planned
depends_on: [p9-fb-01]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 ingest]
source_feedback: p9-dogfooding-feedback.md item 2
---
# p9-fb-04 — Ingest cancellation
## Goal
ingest 가 사용자 cancel 신호 (Ctrl-C / Esc) 받으면 step boundary 에서 즉시 중단. 부분 진행은 SQLite 에 commit 된 상태 유지 — resume 은 idempotent.
## Allowed dependencies
- `std::sync::atomic::AtomicBool` (Arc 공유). 외부 crate X.
## Public surface
```rust
#[doc(hidden)]
pub fn ingest_with_config_cancellable(
config: kebab_config::Config,
scope: SourceScope,
summary_only: bool,
progress: Option<Sender<IngestEvent>>,
cancel: Option<Arc<AtomicBool>>,
) -> anyhow::Result<IngestReport>;
```
기존 `ingest_with_config_progress` (p9-fb-01) 가 `cancel=None` forwarding.
## Behavior contract
- check 위치 (step boundary): asset loop iteration 시작, embed batch 시작, vector upsert 직후. 가장 긴 wait 인 LLM 호출 (OCR / caption) 도 가능하면 token boundary 에서 check (Ollama HTTP 응답 stream 이라 partial cancel 가능).
- cancel triggered 시: 현재 in-flight asset 마무리 (rollback 하면 idempotent 깨질 수 있음 — commit 후 종료가 안전), 이후 asset 미실행, `IngestEvent::Aborted { partial_counts }` 발신, `Ok(IngestReport)` 반환 (Err 가 아님 — 정상 종료의 한 형태).
- CLI `kebab ingest`: Ctrl-C SIGINT handler 가 `cancel.store(true, Ordering::Relaxed)`. 두 번째 Ctrl-C 는 hard exit.
- TUI: `Esc` (ingest 진행 중에만) 또는 `Ctrl-C` 가 cancel signal. p9-fb-03 의 `IngestState.cancel_tx` 와 wiring.
## Test plan
| kind | description |
|------|-------------|
| unit | cancel=true set 후 다음 step 에서 Aborted event |
| integration | 100 md fixture, idx=10 cancel → DB 에 10 docs 만 commit, idempotent re-ingest 가능 |
## DoD
- [ ] `cargo test -p kebab-app` 통과
- [ ] CLI Ctrl-C handler test
- [ ] TUI Esc cancel test
- [ ] HOTFIXES X (신규)
- [ ] README — Ctrl-C 동작 명시
## Out of scope
- resume from checkpoint (현재는 idempotent re-run 으로 충분)
- embed / RAG streaming cancel (별도 task)

View File

@@ -0,0 +1,67 @@
---
phase: P9
component: kebab-config + kebab-cli (init) + README
task_id: p9-fb-05
title: "workspace.root path policy (relative? + init placeholder + README)"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§6.2 workspace]
source_feedback: p9-dogfooding-feedback.md item 3
---
# p9-fb-05 — Path policy
## Goal
`workspace.root` 의 허용 형식 명확화: tilde / 절대 / 상대 경로 모두 지원하되 base 정의 명시. `kebab init` 가 생성하는 placeholder + 코멘트 + README 도 동시 갱신.
## Allowed dependencies
- 기존 kebab-config deps.
## Public surface
`kebab_config::expand_path` 가 이미 tilde + env 처리. relative path 처리 추가:
```rust
/// `path` 를 expand. relative 인 경우 `base_dir` 기준으로 절대화.
pub fn expand_path_with_base(path: &str, data_dir: &str, base_dir: &Path) -> PathBuf;
```
`workspace.root` 의 base_dir 은 **config.toml 자체가 위치한 디렉토리**. config 가 따라다니므로 사용자의 cwd 무관.
## Behavior contract
- 허용 형식: 절대 (`/foo/bar`) / tilde (`~/KnowledgeBase`) / env (`${XDG_DATA_HOME}/...`) / 상대 (`./notes`, `notes`, `../parent/x`).
- 상대 경로의 base = config 파일 dir. `--config /tmp/test/config.toml` + `root = "kb"``/tmp/test/kb`.
- `kebab init` placeholder: `~/KnowledgeBase` 그대로 — tilde 가 가장 친숙. config.toml 코멘트로 base 정의 명시:
```toml
[workspace]
# 절대 / `~` / `${VAR}` / 상대 경로 모두 가능. 상대 경로는
# 이 config.toml 이 있는 디렉토리 기준.
root = "~/KnowledgeBase"
```
- README **Configuration** 절에 base 정의 추가.
- SMOKE.md 의 `/tmp/kebab-smoke/config.toml` 예시도 갱신 가능 (기존 절대 경로라 OK).
## Test plan
| kind | description |
|------|-------------|
| unit | `expand_path_with_base("./notes", "", "/tmp/test")` → `/tmp/test/notes` |
| unit | `expand_path_with_base("~/x", "", "/tmp/test")` → `$HOME/x` |
| integration | `kebab ingest --config /tmp/cfg.toml` (root 가 상대) 가 cfg dir 기준 |
## DoD
- [ ] `cargo test -p kebab-config` 통과
- [ ] `kebab-app::ingest` 의 root expand 가 `expand_path_with_base` 로 통일
- [ ] `kebab init` 코멘트 갱신
- [ ] README + SMOKE.md 동시 갱신
## Out of scope
- `expand_tilde` helper 통일 (P+ — HOTFIXES caveat)
- 다른 경로 필드 (`storage.data_dir` 등) policy 변경 — 현재 그대로 OK

View File

@@ -0,0 +1,64 @@
---
phase: P9
component: kebab-cli + kebab-app
task_id: p9-fb-06
title: "kebab reset / nuke command"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 4
---
# p9-fb-06 — Reset 명령
## Goal
`kebab reset` 단일 명령으로 XDG 데이터 wipe. 부분 wipe variant 도 제공.
## Allowed dependencies
- 기존 + `dialoguer` (또는 자체 prompt) — confirm UI.
## Public surface
CLI:
```
kebab reset [--all | --data-only | --vector-only | --config-only] [--yes]
```
flag 의미:
- `--all`: 4 XDG 경로 전부 (config + data + cache + state).
- `--data-only`: data + cache + state. config 보존 (기본).
- `--vector-only`: lance dir 만. SQLite 보존 + re-embed 필요한 chunks 표시.
- `--config-only`: config dir 만.
- `--yes`: confirm prompt skip.
`--config <path>` 도 honor — isolated workspace wipe.
## Behavior contract
- 기본 confirm: 삭제 대상 경로 4 줄 + 총 byte 추정 + `(y/N)`. n 또는 빈 입력은 abort.
- TTY 아닌 경우 `--yes` 없이 abort (silent destruction 금지).
- vector-only 의 경우: SQLite `embedding_records` row 도 같이 truncate (orphan 방지). re-embed 는 next ingest 가 처리.
- wipe 후 `kebab init` 자동 호출 X — 사용자가 명시적으로 다시 init.
## Test plan
| kind | description |
|------|-------------|
| unit | path 추정 + confirm 메시지 빌드 |
| integration | tmp config + `--data-only --yes` → data dir 삭제, config 보존 |
| integration | `--vector-only --yes` → lance dir 사라짐, embedding_records=0 |
## DoD
- [ ] `cargo test -p kebab-cli` 통과
- [ ] README **명령** 표 + Quick start 갱신 (reset 명령 + safety 안내)
- [ ] 위험성 강조: README + `--help` 에서 "irreversible"
## Out of scope
- snapshot / backup 생성 (P+ — `kebab backup` 별도)
- confirm 우회용 env (`KEBAB_RESET_YES=1` 같은 magic) 금지 — `--yes` 로 충분

View File

@@ -0,0 +1,61 @@
---
phase: P9
component: kebab-parse-md + kebab-normalize
task_id: p9-fb-07
title: "Markdown title fallback chain (frontmatter → H1 → H2 → first paragraph → filename)"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§3.5 ParsedDoc, §5.5 CanonicalDocument]
source_feedback: p9-dogfooding-feedback.md item 5
---
# p9-fb-07 — Title fallback
## Goal
frontmatter `title` / 첫 H1 둘 다 없는 markdown 도 의미 있는 title 표시. fallback chain 명시 + parser_version cascade.
## Allowed dependencies
- 기존 kebab-parse-md / kebab-normalize. 신규 X.
## Public surface
`kebab-normalize::derive_title(parsed: &ParsedDoc, file_stem: &str) -> String` 가 chain 구현. CanonicalDocument.title 채움.
## Behavior contract
fallback 우선순위:
1. frontmatter `title` (기존)
2. 첫 H1 텍스트 (기존)
3. 첫 H2 텍스트 (신규)
4. 첫 non-empty paragraph 의 첫 80 자 (인용 / list 제외)
5. 파일명 (확장자 제외, kebab-case 유지)
빈 결과 / whitespace 만이면 다음 단계로 진행. 모든 단계 실패 시 (frontmatter only no body file) 파일명. 빈 문자열 반환 금지.
`parser_version` (현재 `md-frontmatter-v1`) → `md-frontmatter-v2` bump. 기존 doc 은 next ingest 시 재처리 (same `doc_id` recipe → upsert).
## Test plan
| kind | description |
|------|-------------|
| unit | frontmatter title only → 1단계 |
| unit | H2 부터 시작 → 3단계 |
| unit | 표만 있는 doc → 4단계 (paragraph 없음 → 5단계 filename) |
| unit | 한글 H1 → NFC 정규화된 title |
| snapshot | corpus 의 python-360-... → "Annotation issues at runtime" |
## DoD
- [ ] `cargo test -p kebab-parse-md -p kebab-normalize` 통과
- [ ] parser_version 갱신 (`md-frontmatter-v2`)
- [ ] HOTFIXES X — bump 은 정상 cascade 동작
- [ ] 사용자에게 "title 비어 있던 doc 은 `kebab ingest` 다시 돌리면 채워짐" 안내 (README 또는 changelog)
## Out of scope
- PDF / 이미지 doc 의 title fallback (별도 task — 다른 parser)
- AI 로 title 추출 (P+)

View File

@@ -0,0 +1,53 @@
---
phase: P9
component: kebab-tui (search pane)
task_id: p9-fb-08
title: "Search debounce + Enter-immediate trigger"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 6
---
# p9-fb-08 — Search debounce
## Goal
TUI search pane 의 keystroke-by-keystroke 검색 제거. debounce 250ms + Enter 즉시 trigger.
## Allowed dependencies
- 기존 kebab-tui deps.
## Public surface
`kebab-tui::search::SearchState``debounce_at: Option<Instant>` 추가. main run-loop tick 에서 check.
## Behavior contract
- 글자 입력 / backspace → `debounce_at = Instant::now() + 250ms`. 기존 in-flight worker 는 cancel 신호 받음 (다음 step 에서 drop, 결과 stale 으로 폐기).
- main loop 가 매 tick 마다 `if Instant::now() >= debounce_at && state.dirty { spawn search worker; debounce_at=None }`.
- Enter 누름 → debounce 무시 즉시 spawn.
- 같은 query 로 재 spawn 방지 (간단 dedupe — 직전 query 와 비교).
- worker 결과 도착 시 generation counter 비교: 사용자가 추가 입력해 query 가 바뀌면 stale 결과 drop.
- generation counter pattern 은 p9-fb-19 cache 와 같은 prerequisite — 코드 공유.
## Test plan
| kind | description |
|------|-------------|
| unit | 글자 5 회 빠르게 입력 → worker spawn 1 회 |
| unit | Enter 즉시 spawn |
| unit | 입력 → 결과 도착 → 추가 입력 → stale drop |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] README TUI search 절에 debounce 동작 명시
## Out of scope
- search 결과 캐싱 (p9-fb-19 별도)
- CLI search 동작 변경 (CLI 는 단발)

View File

@@ -0,0 +1,58 @@
---
phase: P9
component: kebab-tui
task_id: p9-fb-09
title: "External editor return — terminal restore + force redraw"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 7
---
# p9-fb-09 — Editor return restore
## Goal
`g` (search), `o` (citation jump 후속) 으로 vim/code 띄우고 종료 후 TUI 화면이 깨지는 버그 수정.
## Allowed dependencies
- 기존 kebab-tui deps.
## Public surface
`kebab-tui::run` 내부에 `with_external_program(|term| -> Result<()> { ... })` helper. spawn 직전 / 종료 후 terminal 상태 toggle.
## Behavior contract
spawn 직전:
1. `terminal.show_cursor()`
2. `terminal.backend_mut().execute(LeaveAlternateScreen)?`
3. `disable_raw_mode()?`
child wait 후:
1. `enable_raw_mode()?`
2. `EnterAlternateScreen`
3. `terminal.clear()?` — 강제 redraw
4. main loop 의 `force_redraw_next_frame: bool` flag set → 다음 draw 가 dirty rect 무시 전체 그림.
## Test plan
수동 테스트 (단위 테스트 어려움 — terminal io 의존). 하지만 helper 의 sequence 자체는 mock backend 로 검증 가능.
| kind | description |
|------|-------------|
| unit | mock backend 의 호출 sequence 검증 (Leave → spawn → Enter → Clear) |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] 도그푸딩: `g` 누르고 `:q` 후 화면 정상 redraw 확인
- [ ] 같은 helper 가 p9-fb-20 의 citation jump 에도 사용
## Out of scope
- editor 종료 코드 처리 (실패해도 TUI 복귀)
- editor stdin 전달 (현재 path 만)

View File

@@ -0,0 +1,53 @@
---
phase: P9
component: kebab-tui
task_id: p9-fb-10
title: "CJK input + wide-char rendering audit"
status: planned
depends_on: [p9-fb-12]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 8
---
# p9-fb-10 — CJK input
## Goal
한글 / 일본어 / 중국어 입력 + 출력이 깨지지 않게. mode machine (p9-fb-12) 위에 IME safe 입력 흐름.
## Allowed dependencies
- `unicode-width = "0.2"` (이미 워크스페이스에 있는지 확인 후 도입).
## Public surface
`kebab-tui::input::InputBuffer` — String + cursor (column 단위 wide-char width 인지). ratatui Span 렌더링 시 `unicode-width::UnicodeWidthStr` 로 정확한 width.
## Behavior contract
- IME composing event: crossterm 은 native IME composing surface X — 자모 단위 `KeyCode::Char(c)` 로 도착. mode machine (p9-fb-12) 에서 INSERT 모드면 모든 Char 가 buffer push, NORMAL 모드면 single-key command.
- wide char width: `c.width()` 로 cursor column 진행. ASCII=1, CJK=2.
- buffer 의 byte index vs char index 구분 — backspace 는 마지막 char (`pop_char`) 단위 삭제, byte slice 금지.
- 한글 fixture 추가: `fixtures/markdown/한글-테스트.md`, query `러스트 비동기`, 답변 streaming `테스트 답변` 등.
## Test plan
| kind | description |
|------|-------------|
| unit | InputBuffer 한글 5 자 push → display_width = 10 |
| unit | backspace 가 자모 1 단위가 아닌 완성형 글자 1 단위 (utf-8 char boundary) |
| unit | 한글 query → SQLite FTS5 정상 검색 (이미 NFC 정규화) |
| integration | TUI run-loop 모의로 한글 query 입력 → status bar 글자 깨짐 X |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] 한글 fixture 추가
- [ ] README — CJK 입력 동작 정상 명시
## Out of scope
- macOS IME (Korean composing 시 system level) 회피 — fallback 안내 (외부 editor 사용 권장)
- emoji surrogate pair (현재 pulldown-cmark 가 처리)

View File

@@ -0,0 +1,64 @@
---
phase: P9
component: kebab-tui (ask pane)
task_id: p9-fb-11
title: "Ask answer markdown rendering (bold/italic/code/list/table)"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG, §10 UX]
source_feedback: p9-dogfooding-feedback.md item 9
---
# p9-fb-11 — Ask markdown render
## Goal
ask 답변의 markdown 문법을 ratatui Span / Line 으로 변환해 시각 구분. raw `**bold**` 사라지고 실제 bold 표시.
## Allowed dependencies
- `pulldown-cmark` (이미 워크스페이스에 있음).
- ratatui (기존).
## Public surface
`kebab-tui::markdown::render(text: &str, theme: &Theme) -> Vec<Line<'static>>`. theme 은 p9-fb-14.
## Behavior contract
inline:
- `**bold**` / `__bold__``Modifier::BOLD`.
- `*italic*` / `_italic_``Modifier::ITALIC`.
- inline code `` ` `` → bg `theme.code_bg`.
- 링크 `[text](url)` → underline + theme.link.
block:
- heading `#`, `##`, ... → fg color 에 따라 hierarchy.
- list bullet `-` / `*` / `1.` → indent + bullet char.
- code fence ``` ``` → 박스 widget + monospace assumed.
- table `| col |` → ratatui `Table` widget. column auto-width.
- blockquote `>` → 좌측 vertical bar + dim fg.
streaming 처리: 마지막 incomplete inline span (e.g. 닫지 않은 `**`) 은 raw 로 표시. complete 부분만 styled. 매 frame 재 parse — cheap, ms 단위.
## Test plan
| kind | description |
|------|-------------|
| unit | `**hi**` → 1 Span with BOLD modifier |
| unit | code fence → CodeBlock 변환 |
| unit | table 2x2 → ratatui Table |
| snapshot | 복합 답변 (heading + list + code) → snapshot 비교 |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] 도그푸딩: bold / italic / table 답변 정상 렌더
- [ ] CLI ask 출력은 raw markdown 유지 (terminal 호환성)
## Out of scope
- 이미지 (markdown img tag) 렌더링 — 터미널 한계
- 링크 클릭 / 따라가기 (P+)

View File

@@ -0,0 +1,78 @@
---
phase: P9
component: kebab-tui
task_id: p9-fb-12
title: "TUI mode state machine (NORMAL / INSERT)"
status: planned
depends_on: []
unblocks: [p9-fb-10, p9-fb-13]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 10
---
# p9-fb-12 — Mode machine
## Goal
TUI 전체에 vim 식 NORMAL / INSERT 모드 도입. 입력 모호성 (e/j/k 가 typing vs command) 제거.
## Allowed dependencies
- 기존 kebab-tui deps.
## Public surface
```rust
pub enum Mode { Normal, Insert }
pub(crate) struct App {
mode: Mode,
// ... 기존
}
```
key dispatch 가 mode 따라 분기.
## Behavior contract
기본 진입 모드: NORMAL (Library 가 starting pane). Search / Ask 는 query 칠 일이 잦으므로 pane 전환 시 자동 INSERT 진입 (configurable, 우선 자동).
NORMAL 모드 키 (전역):
- `i` → INSERT
- `:` → command line (`:q` quit, `:cite`, `:new` 등)
- `j/k`, `g/G`, `Ctrl-d/u`, `PageDown/Up` → scroll
- `Tab/Shift-Tab` → pane 이동
- `?` → cheatsheet popup (p9-fb-13)
- pane 별 키 (e=explain, c=cite toggle, r=refresh, …)
INSERT 모드 키:
- 모든 `Char` → input buffer push
- `Esc` → NORMAL
- `Enter` → submit (search / ask trigger)
- `Backspace`, 화살표 키 → buffer 편집 + cursor 이동
- 기타 navigation 키 (j/k 등) 는 typing 으로만
status bar 표시: `-- INSERT --` / `-- NORMAL --` (color: INSERT=green, NORMAL=blue).
focus 표시: 활성 pane 의 테두리 색 강조.
기존 P9-3 ask 의 e/j/k input-empty heuristic 제거 — mode 로 명확히.
## Test plan
| kind | description |
|------|-------------|
| unit | NORMAL 에서 `j` → scroll, INSERT 에서 `j` → buffer 'j' |
| unit | `i` → INSERT, `Esc` → NORMAL |
| unit | Search pane 전환 시 자동 INSERT |
| integration | mode 전환 + key sequence snapshot |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] 기존 input-empty heuristic 제거 (HOTFIXES P9-3 e/j/k 갱신)
- [ ] README + cheatsheet (p9-fb-13) 갱신
## Out of scope
- VISUAL 모드 (P+)
- mode 별 cursor shape (`Block` vs `Bar`) — 터미널마다 다름, 우선 skip

View File

@@ -0,0 +1,59 @@
---
phase: P9
component: kebab-tui + README
task_id: p9-fb-13
title: "Cheatsheet popup (?) + README keymap table + verb hint line"
status: planned
depends_on: [p9-fb-12]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 11
---
# p9-fb-13 — Cheatsheet
## Goal
vim 비익숙 사용자도 TUI 조작 가능. `?` modal popup + 동사구 hint line + README keymap 표.
## Allowed dependencies
- 기존 kebab-tui deps.
## Public surface
`kebab-tui::cheatsheet::Cheatsheet` widget. mode + 현재 pane 별 분기.
## Behavior contract
- `?` 키 (NORMAL 만) → modal popup. 화면 중앙 70% box, 키 + 동사구 설명 표.
- popup 안에서 `?` 또는 `Esc` → close.
- pane 별 cheatsheet 분리 — Library / Search / Ask / Inspect 각각 다른 키. 공통 키 (Tab pane 이동, q quit) 는 footer 영역.
- hint line (status bar 위 1 줄) — 동사구로:
- 기존: `j/k=move`
- 신규: `↑/k 위로 ↓/j 아래로 Enter 선택 Esc 취소`
- mode 따라 hint line 다름 (p9-fb-12 와 wire). NORMAL = navigation, INSERT = `Esc 로 명령모드`.
README 갱신:
- **TUI 키 매핑** 표 (전역 + pane 별).
- vim 비유 안내 한 줄 ("vim 처럼 i 로 입력, Esc 로 명령").
## Test plan
| kind | description |
|------|-------------|
| unit | `?` press → cheatsheet visible flag |
| unit | mode + pane 변경 시 hint line 텍스트 변화 |
| snapshot | popup 의 키 표 snapshot (Library / Search / Ask / Inspect) |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] README **TUI** 절에 키 매핑 표 + cheatsheet 안내
- [ ] 도그푸딩: 첫 사용자가 `?` 만 알면 나머지 발견 가능
## Out of scope
- 사용자 정의 keymap 파일 (P+)
- popup 의 검색 (`/` 로 키 찾기) — 우선 skip

View File

@@ -0,0 +1,69 @@
---
phase: P9
component: kebab-tui
task_id: p9-fb-14
title: "TUI color theme module (role-based + dark/light toggle)"
status: planned
depends_on: []
unblocks: [p9-fb-11]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 12
---
# p9-fb-14 — Color theme
## Goal
TUI 의 정보 종류별 color role 매핑. 모든 pane 이 single source 인 `theme` 모듈에서 Style 가져옴.
## Allowed dependencies
- 기존 kebab-tui deps.
## Public surface
```rust
pub struct Theme { /* role -> Style 맵 */ }
impl Theme {
pub fn dark() -> Self;
pub fn light() -> Self;
pub fn style(&self, role: Role) -> Style;
}
pub enum Role {
Title, Path, ScoreHigh, ScoreMid, ScoreLow,
ModeLexical, ModeVector, ModeHybrid,
Warning, Error, StreamingNew,
CitationLink, KeywordHighlight,
ModeNormal, ModeInsert,
BorderActive, BorderInactive,
Bullet, CodeBg, BlockquoteBar,
}
```
## Behavior contract
- 기본 theme: dark.
- `theme = "dark" | "light"` config field 신규. `T` 키 (NORMAL 모드) toggle.
- color role 매핑 — 이전 항목 12 의 role 표 따름.
- accessibility: color 단독 의미 전달 X. Score 는 숫자 + color, mode 는 텍스트 + color.
## Test plan
| kind | description |
|------|-------------|
| unit | `Theme::dark().style(Role::Title)` 가 정의된 fg/bg 반환 |
| unit | dark / light 의 모든 Role 변형 정의 누락 X (exhaustive match) |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] Library / Search / Ask / Inspect 의 직접 `Style::default().fg(...)` 호출 사라짐 (모두 theme 경유)
- [ ] config.toml 코멘트에 `theme = "dark"` 명시
- [ ] README — theme 토글 키 안내
## Out of scope
- 사용자 정의 color (`[theme.custom]` 절) — P+
- terminal 의 truecolor 미지원 fallback (256-color assumed)

View File

@@ -0,0 +1,74 @@
---
phase: P9
component: kebab-rag + kebab-app
task_id: p9-fb-15
title: "RAG multi-turn — history-aware prompt + token budget"
status: planned
depends_on: []
unblocks: [p9-fb-16, p9-fb-17, p9-fb-18]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG]
source_feedback: p9-dogfooding-feedback.md item 13
---
# p9-fb-15 — RAG multi-turn core
## Goal
`kebab-rag` 가 conversation history (`Vec<Turn>`) 를 받아 prompt 빌드. token budget 안에서 retrieval k 와 history truncation 정책으로 fit.
## Allowed dependencies
- 기존 kebab-rag deps.
- `tiktoken-rs` 또는 LLM family-specific tokenizer (gemma 토큰화). 우선 char 기반 ÷4 근사 (cheap & 의존 X).
## Public surface
```rust
pub struct Turn {
pub question: String,
pub answer: String,
pub citations: Vec<Citation>,
pub ts: OffsetDateTime,
}
pub fn ask_with_history(
cfg: &Config,
new_question: &str,
history: &[Turn],
stream: Sender<RagEvent>,
) -> anyhow::Result<Answer>;
```
`kebab-app``ask_with_config_and_history(cfg, q, history, stream)` 추가.
## Behavior contract
- prompt 구조: `system_prompt + history_serialized + retrieved_chunks + new_question`. 형식 (roles 또는 plain text) 는 `prompt_template_version` bump (`rag-v1``rag-v2`).
- token budget: `cfg.rag.max_context_tokens`.
- 우선순위: system + new_question 항상 포함.
- 다음: retrieved chunks (k=cfg.search.default_k 부터, budget 초과시 k 감소).
- 마지막: history. budget 남은 만큼 newest turn 부터 포함, 부족하면 oldest turn drop. 최소 0 turn 까지 가능.
- retrieval query: `new_question + " " + last_turn.answer.first_N_chars(200)` concat (cheap query expansion). LLM 기반 standalone question rewriting 은 P+.
- streaming: `RagEvent::Token(s)` / `RagEvent::Done(answer)` / `RagEvent::Error(e)`.
## Test plan
| kind | description |
|------|-------------|
| unit | history 5 turn → token budget 초과 시 oldest 부터 drop |
| unit | retrieved_chunks vs history 의 priority |
| integration | 가짜 history (Q1/A1) + new Q2 → prompt 에 Q1/A1 포함 (snapshot) |
## DoD
- [ ] `cargo test -p kebab-rag -p kebab-app` 통과
- [ ] `prompt_template_version` bump (`rag-v2`)
- [ ] HOTFIXES X (신규)
- [ ] frozen design §7 RAG 절 갱신 (multi-turn 정책)
## Out of scope
- LLM 기반 question rewriting (P+)
- conversation 영속화 (p9-fb-17)
- UI (p9-fb-16, p9-fb-18)

View File

@@ -0,0 +1,65 @@
---
phase: P9
component: kebab-tui (ask pane)
task_id: p9-fb-16
title: "TUI ask conversation transcript view"
status: planned
depends_on: [p9-fb-15, p9-fb-12]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§10 UX]
source_feedback: p9-dogfooding-feedback.md item 13
---
# p9-fb-16 — Ask conversation UI
## Goal
ask pane 을 단발 Q/A 에서 conversation transcript 로 전환. 이전 turn 들 scrollback 가능.
## Allowed dependencies
- 기존 kebab-tui deps.
## Public surface
`kebab-tui::ask::AskState` 가 기존 `latest_question / latest_answer` 대신 `Vec<Turn>` 보유. p9-fb-15 의 `Turn` 재사용.
## Behavior contract
layout:
- 위 (대부분 영역): conversation transcript. 각 turn 은 `Q: ... \n A: ...\n ▸ 근거 N 건` 블록.
- 아래 1~3 줄: input box + status.
- 좌측 / 우측 padding 으로 readability.
키 (NORMAL, p9-fb-12 따름):
- `j/k`, `PageDown/Up`, `Ctrl-d/u` → transcript scroll.
- `G` → 끝, `gg` → 처음.
- `c` → 현재 focus turn 의 citation fold/unfold.
- `Ctrl-L` 또는 `:new` → history clear (현 session reset, 영속은 p9-fb-17).
- `i` → INSERT (input box focus), `Esc` → NORMAL.
- `Enter` (INSERT) → submit. spawn worker (기존 P9-3 pattern).
streaming:
- 새 token 도착 시 마지막 Turn 의 answer 에 append. transcript 가장 아래까지 자동 scroll (사용자가 위로 scroll 한 상태면 자동 scroll 안 함, "↓ N new tokens" 표시).
## Test plan
| kind | description |
|------|-------------|
| unit | Turn push 후 layout 변경 |
| unit | Ctrl-L → history empty |
| unit | streaming token append → 마지막 Turn.answer 누적 |
| integration | 가짜 RagEvent stream 으로 2 turn 시퀀스 snapshot |
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] README — ask pane 의 conversation 동작 + 키 안내 추가
- [ ] HOTFIXES P9-3 ask pane 의 단발 동작 → 갱신
## Out of scope
- 영속화 (p9-fb-17)
- CLI 의 multi-turn (p9-fb-18)
- citation fold/unfold 의 jump 키 (p9-fb-20)

View File

@@ -0,0 +1,77 @@
---
phase: P9
component: kebab-store-sqlite
task_id: p9-fb-17
title: "SQLite V004 — chat_sessions / chat_turns"
status: planned
depends_on: [p9-fb-15]
unblocks: [p9-fb-18]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§5 storage]
source_feedback: p9-dogfooding-feedback.md item 13, 14
---
# p9-fb-17 — Chat session storage
## Goal
multi-turn 대화 영속화. session 단위로 turn 저장 / 조회. TUI 의 "이전 대화 이어가기", CLI `--session <id>` (p9-fb-18) 의 backing store.
## Allowed dependencies
- `kebab-store-sqlite` 기존 deps (rusqlite, refinery).
## Public surface
마이그레이션 `migrations/V004__chat_sessions.sql`:
```sql
CREATE TABLE chat_sessions (
session_id TEXT PRIMARY KEY, -- 사용자 지정 또는 blake3 해시
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
title TEXT, -- 첫 question 의 첫 N 자
config_snapshot_json TEXT NOT NULL -- prompt_template_version, llm model 등
);
CREATE TABLE chat_turns (
turn_id TEXT PRIMARY KEY, -- blake3(session_id || index)
session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
turn_index INTEGER NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
citations_json TEXT NOT NULL, -- Vec<Citation> 직렬화
created_at INTEGER NOT NULL,
UNIQUE(session_id, turn_index)
);
CREATE INDEX idx_chat_turns_session ON chat_turns(session_id, turn_index);
```
`kebab_core::ChatSessionRepo` trait + `SqliteStore` impl.
## Behavior contract
- session_id 사용자 명시 (`kebab ask --session foo`) 또는 자동 생성 (blake3 of first_question + ts).
- turn_index monotonic per session.
- `ON DELETE CASCADE``kebab reset --data-only` (p9-fb-06) 가 wipe.
- `config_snapshot_json` 는 prompt_template_version + llm.model + max_context_tokens 등 — 후일 retrospective 분석 가능.
## Test plan
| kind | description |
|------|-------------|
| unit | session 생성, 3 turn append, list_turns sequence |
| unit | session delete → CASCADE turns |
| migration | V004 apply 후 schema_version table 갱신 |
## DoD
- [ ] `cargo test -p kebab-store-sqlite` 통과
- [ ] `migrations/V004__chat_sessions.sql` 추가
- [ ] `kebab_core::ChatSessionRepo` trait 정의
- [ ] frozen design §5 storage 절에 chat_sessions / chat_turns 추가
## Out of scope
- session 검색 / 필터 UI (P+)
- 다른 store backend (postgres 등)

View File

@@ -0,0 +1,60 @@
---
phase: P9
component: kebab-cli + kebab-app
task_id: p9-fb-18
title: "CLI ask --session / --repl"
status: planned
depends_on: [p9-fb-15, p9-fb-17]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG, §externalAI]
source_feedback: p9-dogfooding-feedback.md item 14
---
# p9-fb-18 — CLI ask multi-turn
## Goal
CLI 에서도 conversation history 사용. `--session <id>` (영속) + `--repl` (in-memory loop).
## Allowed dependencies
- 기존 kebab-cli deps + p9-fb-17 의 ChatSessionRepo.
## Public surface
CLI:
```
kebab ask "Q" [--session <id>] [--repl]
```
- 둘 다 없음: 단발 (현 동작 유지).
- `--session foo`: SQLite chat_sessions 에 `foo` 가 있으면 history 로 사용 + 새 turn append. 없으면 새 session 생성.
- `--repl`: stdin loop. 각 question 후 답변 출력. `:q` 또는 EOF 종료. `--session` 결합 시 영속, 아니면 in-memory.
- `--repl` 에서 빈 줄 + `:` 명령: `:q` (quit), `:new` (session reset, in-memory), `:save <id>` (현 in-memory → session 저장 + 이후 영속).
`--json` 모드: line-delimited 답변 JSON. 각 줄 `answer.v1` (이미 정의), `conversation_id` + `turn_index` 필드 추가 (p9-fb-15 의 schema bump 와 함께).
## Behavior contract
- session 없이 단발 호출은 wire schema `answer.v1``conversation_id` 필드 = null. 호환.
- `--repl` 에서 Ctrl-C → graceful exit. session 저장된 상태면 finalized.
## Test plan
| kind | description |
|------|-------------|
| unit | `--session foo` 첫 호출 → 새 session 생성 |
| unit | `--session foo` 두번째 호출 → 이전 turn history 로 prompt 빌드 |
| integration | `--repl` stdin "Q1\nQ2\n:q\n" → 2 답변 + clean exit |
## DoD
- [ ] `cargo test -p kebab-cli` 통과
- [ ] `answer.v1` schema 갱신 (conversation_id / turn_index 추가, optional)
- [ ] README **명령** 표 + **외부 AI 통합** 절 — `--session` 으로 Claude Code skill / MCP 가 multi-turn 가능
## Out of scope
- session list / show / delete CLI 명령 (P+)
- session export (markdown / JSON dump)

View File

@@ -0,0 +1,76 @@
---
phase: P9
component: kebab-search + kebab-app
task_id: p9-fb-19
title: "Search result cache (in-memory LRU + index_version invalidation)"
status: planned
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 search, §9 versioning]
source_feedback: p9-dogfooding-feedback.md item 15
---
# p9-fb-19 — Search cache
## Goal
같은 query 반복 시 SQLite FTS + Lance + RRF 재계산 회피. 우선 in-memory LRU + `index_version` bump 기반 단순 invalidation.
## Allowed dependencies
- `lru = "0.12"` (검증된 LRU crate).
## Public surface
`kebab-app::App``Mutex<LruCache<CacheKey, Vec<SearchHit>>>` 필드. 호출자 (CLI / TUI) 는 변경 없이 cached 결과 받음.
```rust
struct CacheKey {
query_norm: String, // NFKC + trim + lowercase
mode: SearchMode,
k: u32,
snippet_chars: u32,
embedding_version: String,
chunker_version: String,
index_version: u64,
}
```
CLI: `kebab search --no-cache "..."` 로 강제 bypass.
## Behavior contract
- LRU capacity: 256 entry (cfg.search.cache_capacity, default 256). 메모리 한정 — 1 entry ≈ 5KB → 1.3MB 상한.
- normalize: query 정규화 후 같은 entry. 사용자 입력 trim 차이가 redundant compute 안 만듦.
- `index_version`: SQLite `kv` 테이블의 `kv['index_version']` 단조 증가 카운터. ingest 가 1 chunk 라도 변경하면 +1. embedding 만 추가/삭제도 +1. bump 시 cache 의 모든 entry 가 stale (index_version 키 비교).
- LRU evict / stale entry 는 next miss 시 자동 garbage. 명시적 wipe API 도 제공 (`App::clear_search_cache()`).
- TTL: in-memory LRU 라 process 수명. 영속 cache (SQLite) 는 P+.
## Test plan
| kind | description |
|------|-------------|
| unit | 같은 query 2 회 → 두번째 cache hit |
| unit | ingest → index_version+1 → 같은 query stale → recompute |
| unit | NFKC 정규화: "Foo" / "FOO" / " Foo " 같은 entry |
| unit | LRU evict: capacity+1 entry 삽입 → 가장 오래된 evict |
| integration | `--no-cache` flag 가 cache bypass |
## DoD
- [ ] `cargo test -p kebab-search -p kebab-app` 통과
- [ ] `index_version` SQLite 컬럼 + ingest 가 bump
- [ ] frozen design §9 versioning 에 `index_version` 추가
- [ ] README — `--no-cache` 안내
## Out of scope
- patch-and-merge incremental (사용자가 말한 "추가만 끼워넣기") — P+ task. 우선 stale 시 전체 recompute.
- SQLite 영속 cache — P+.
- per-process 공유 cache (RwLock 다른 process 간) — P+.
## Risks / notes
- patch-and-merge 가 더 효율적이지만 RRF normalization 이 hit set 전체 기준 (`2/(k+1)`) 이라 incremental 어려움. 우선 단순 LRU 가 도그푸딩 막힘 해결.
- `index_version` 신규 — versioning cascade (§9) 에 새 차원 추가. 기존 5 개 (parser/chunker/embedding/prompt_template/index 그 자체 의미 다름) 와 구분 필요. 명확화 작업이 spec 갱신 동반.

View File

@@ -0,0 +1,72 @@
---
phase: P9
component: kebab-cli + kebab-tui
task_id: p9-fb-20
title: "Citation full path + scrollable pane (CLI block + TUI pane + jump)"
status: planned
depends_on: [p9-fb-09, p9-fb-16]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG, §10 UX]
source_feedback: p9-dogfooding-feedback.md item 16
---
# p9-fb-20 — Citation surface
## Goal
ask 답변의 citation 을 사용자가 풀 경로로 보고, scroll 하고, 원본으로 점프 가능. CLI / TUI 둘 다.
## Allowed dependencies
- 기존 deps. p9-fb-09 의 editor restore helper 재사용.
## Public surface
CLI:
- `kebab ask "Q" [--show-citations | --hide-citations]` (default: show).
- 출력 형식 (사람-친화):
```
답변:
...
근거:
[1] crates/kebab-app/src/lib.rs#L120-L140 (score=0.78, doc_id=abc123)
[2] notes/foo.md#L12-L34 (score=0.71, doc_id=def456)
```
- `--json` 은 항상 citations 포함 (`answer.v1.citations`, 변경 X).
TUI ask pane (p9-fb-16 conversation 위에):
- 화면 분할: 위 transcript / 아래 input. 추가로 옵션 우측 1/3 citation pane (`c` 키 toggle).
- citation pane 내부:
- 각 항목 한 줄 (full path + line range + score). truncate 안 함 — long path 는 wrap.
- turn 별로 group (`▾ Turn 2 (3)`). fold/unfold.
- 선택 + Enter 또는 `o` → 외부 editor 로 path 점프 (p9-fb-09 helper).
- 선택 + `i` → P9-4 inspect pane 으로 (Doc 또는 Chunk).
## Behavior contract
- TUI citation pane 의 `c` toggle 은 NORMAL 모드 (p9-fb-12 에 등록).
- citation pane 자체 scroll (`j/k`, PageUp/Down) — focus 가 citation pane 일 때.
- focus 이동: `Tab` 으로 transcript / citation 사이.
- editor jump 시 line range 의 시작 line 으로 (`+L120` 옵션 vim/code).
## Test plan
| kind | description |
|------|-------------|
| unit | CLI 출력 형식 snapshot (full path 포함) |
| unit | TUI citation pane fold/unfold |
| unit | `o` 키 → editor spawn (mock) |
| unit | `i` 키 → InspectTarget::Chunk 진입 |
## DoD
- [ ] `cargo test -p kebab-cli -p kebab-tui` 통과
- [ ] README ask 절에 citation 동작 안내
- [ ] HOTFIXES X (신규)
## Out of scope
- citation 의 inline preview (path 위에 마우스 hover 같은) — 터미널 한계
- citation 의 PDF page render (P9-5 desktop 용)