diff --git a/.gitignore b/.gitignore index 32ef371..45c0d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .superpowers/ +.worktrees/ /target/ **/*.rs.bk Cargo.lock.bak diff --git a/docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md b/docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md new file mode 100644 index 0000000..704cb03 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md @@ -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, + 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 { + 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 { + 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 { + 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, + } + + 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 { + 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 ` +2. `gitea-pr-status --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 ` (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 ` 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. diff --git a/tasks/INDEX.md b/tasks/INDEX.md index 635d957..6e3109f 100644 --- a/tasks/INDEX.md +++ b/tasks/INDEX.md @@ -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 핫픽스 diff --git a/tasks/p9/p9-dogfooding-feedback.md b/tasks/p9/p9-dogfooding-feedback.md new file mode 100644 index 0000000..58cddaf --- /dev/null +++ b/tasks/p9/p9-dogfooding-feedback.md @@ -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 files / parsing / embedding chunk /" 식 단계 표시. `--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//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 ` 도 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` + `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 { 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 "Q"`. 같은 `` 로 호출 시 SQLite `chat_sessions` 에 누적. ID 미지정이면 단발 (현재 동작 유지). 13 번의 SQLite V004 와 공유. +- **옵션 B — REPL 모드**: `kebab ask --repl` 실행 시 stdin 로 prompt → answer 반복. Ctrl-D / `:q` 종료. session 은 in-memory (영속 원하면 `--session ` 결합). +- **옵션 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`. + - 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` 기록. 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 안내. diff --git a/tasks/p9/p9-fb-01-ingest-progress-callback.md b/tasks/p9/p9-fb-01-ingest-progress-callback.md new file mode 100644 index 0000000..4c4ea94 --- /dev/null +++ b/tasks/p9/p9-fb-01-ingest-progress-callback.md @@ -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>, +) -> anyhow::Result; +``` + +기존 `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` 가 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) diff --git a/tasks/p9/p9-fb-02-cli-progress-display.md b/tasks/p9/p9-fb-02-cli-progress-display.md new file mode 100644 index 0000000..2dd5568 --- /dev/null +++ b/tasks/p9/p9-fb-02-cli-progress-display.md @@ -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) diff --git a/tasks/p9/p9-fb-03-tui-ingest-background.md b/tasks/p9/p9-fb-03-tui-ingest-background.md new file mode 100644 index 0000000..ee3f60f --- /dev/null +++ b/tasks/p9/p9-fb-03-tui-ingest-background.md @@ -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` slot. p9-3/4 와 동일 parallel-safe pattern. + +```rust +pub(crate) struct IngestState { + rx: Receiver, + counts: AggregateCounts, + current_path: Option, + 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) diff --git a/tasks/p9/p9-fb-04-ingest-cancellation.md b/tasks/p9/p9-fb-04-ingest-cancellation.md new file mode 100644 index 0000000..8cb3f6f --- /dev/null +++ b/tasks/p9/p9-fb-04-ingest-cancellation.md @@ -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>, + cancel: Option>, +) -> anyhow::Result; +``` + +기존 `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) diff --git a/tasks/p9/p9-fb-05-config-path-policy.md b/tasks/p9/p9-fb-05-config-path-policy.md new file mode 100644 index 0000000..793d921 --- /dev/null +++ b/tasks/p9/p9-fb-05-config-path-policy.md @@ -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 diff --git a/tasks/p9/p9-fb-06-data-reset-command.md b/tasks/p9/p9-fb-06-data-reset-command.md new file mode 100644 index 0000000..e1a575f --- /dev/null +++ b/tasks/p9/p9-fb-06-data-reset-command.md @@ -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 ` 도 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` 로 충분 diff --git a/tasks/p9/p9-fb-07-md-title-fallback.md b/tasks/p9/p9-fb-07-md-title-fallback.md new file mode 100644 index 0000000..321175d --- /dev/null +++ b/tasks/p9/p9-fb-07-md-title-fallback.md @@ -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+) diff --git a/tasks/p9/p9-fb-08-search-debounce.md b/tasks/p9/p9-fb-08-search-debounce.md new file mode 100644 index 0000000..48319e3 --- /dev/null +++ b/tasks/p9/p9-fb-08-search-debounce.md @@ -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` 추가. 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 는 단발) diff --git a/tasks/p9/p9-fb-09-tui-editor-restore.md b/tasks/p9/p9-fb-09-tui-editor-restore.md new file mode 100644 index 0000000..ff35401 --- /dev/null +++ b/tasks/p9/p9-fb-09-tui-editor-restore.md @@ -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 만) diff --git a/tasks/p9/p9-fb-10-tui-cjk-input.md b/tasks/p9/p9-fb-10-tui-cjk-input.md new file mode 100644 index 0000000..45a0ba7 --- /dev/null +++ b/tasks/p9/p9-fb-10-tui-cjk-input.md @@ -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 가 처리) diff --git a/tasks/p9/p9-fb-11-ask-markdown-render.md b/tasks/p9/p9-fb-11-ask-markdown-render.md new file mode 100644 index 0000000..5ee7fd8 --- /dev/null +++ b/tasks/p9/p9-fb-11-ask-markdown-render.md @@ -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>`. 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+) diff --git a/tasks/p9/p9-fb-12-tui-mode-machine.md b/tasks/p9/p9-fb-12-tui-mode-machine.md new file mode 100644 index 0000000..3023e49 --- /dev/null +++ b/tasks/p9/p9-fb-12-tui-mode-machine.md @@ -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 diff --git a/tasks/p9/p9-fb-13-tui-cheatsheet.md b/tasks/p9/p9-fb-13-tui-cheatsheet.md new file mode 100644 index 0000000..19e5fe2 --- /dev/null +++ b/tasks/p9/p9-fb-13-tui-cheatsheet.md @@ -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 diff --git a/tasks/p9/p9-fb-14-tui-color-theme.md b/tasks/p9/p9-fb-14-tui-color-theme.md new file mode 100644 index 0000000..d4f4237 --- /dev/null +++ b/tasks/p9/p9-fb-14-tui-color-theme.md @@ -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) diff --git a/tasks/p9/p9-fb-15-rag-multi-turn-core.md b/tasks/p9/p9-fb-15-rag-multi-turn-core.md new file mode 100644 index 0000000..e99ede1 --- /dev/null +++ b/tasks/p9/p9-fb-15-rag-multi-turn-core.md @@ -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`) 를 받아 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, + pub ts: OffsetDateTime, +} + +pub fn ask_with_history( + cfg: &Config, + new_question: &str, + history: &[Turn], + stream: Sender, +) -> anyhow::Result; +``` + +`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) diff --git a/tasks/p9/p9-fb-16-tui-ask-conversation.md b/tasks/p9/p9-fb-16-tui-ask-conversation.md new file mode 100644 index 0000000..26ed901 --- /dev/null +++ b/tasks/p9/p9-fb-16-tui-ask-conversation.md @@ -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` 보유. 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) diff --git a/tasks/p9/p9-fb-17-chat-session-storage.md b/tasks/p9/p9-fb-17-chat-session-storage.md new file mode 100644 index 0000000..38ee55a --- /dev/null +++ b/tasks/p9/p9-fb-17-chat-session-storage.md @@ -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 ` (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 직렬화 + 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 등) diff --git a/tasks/p9/p9-fb-18-cli-ask-session-repl.md b/tasks/p9/p9-fb-18-cli-ask-session-repl.md new file mode 100644 index 0000000..e0dd96a --- /dev/null +++ b/tasks/p9/p9-fb-18-cli-ask-session-repl.md @@ -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 ` (영속) + `--repl` (in-memory loop). + +## Allowed dependencies + +- 기존 kebab-cli deps + p9-fb-17 의 ChatSessionRepo. + +## Public surface + +CLI: +``` +kebab ask "Q" [--session ] [--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 ` (현 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) diff --git a/tasks/p9/p9-fb-19-search-cache.md b/tasks/p9/p9-fb-19-search-cache.md new file mode 100644 index 0000000..422f88a --- /dev/null +++ b/tasks/p9/p9-fb-19-search-cache.md @@ -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>>` 필드. 호출자 (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 갱신 동반. diff --git a/tasks/p9/p9-fb-20-citation-surface.md b/tasks/p9/p9-fb-20-citation-surface.md new file mode 100644 index 0000000..1212251 --- /dev/null +++ b/tasks/p9/p9-fb-20-citation-surface.md @@ -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 용)