Files
kebab/docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md
altair823 a757e2cdb3 review(회차1): 회차 1 지적 5건 반영
- p9-dogfooding-feedback.md item 14: README 오타 (READE → README)
- p9-fb-11.md frontmatter: depends_on=[p9-fb-14] 추가 (14.unblocks 와 양방향 정합)
- p9-fb-01.md Behavior contract: '14 번과 wiring' 모호 cross-ref 정정 — cancel wiring 은 p9-fb-04, TUI 신호는 p9-fb-03
- plan File Structure: 'tasks/HOTFIXES.md — n/a (skip)' 자기모순 제거 → 별도 HOTFIXES 절로 분리
- plan task 4 handler: let _ = data_only; 제거, pattern binding 자체를 data_only: _ 로 변경 (관용적)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:01:14 +00:00

38 KiB

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.rspub mod reset; + re-export ResetScope / ResetReport
  • crates/kebab-store-sqlite/src/embeddings.rspub fn truncate_embedding_records()
  • crates/kebab-cli/src/main.rs — add Cmd::Reset arm + handler
  • crates/kebab-cli/src/wire.rswire_reset helper
  • README.mdkebab reset in 명령 표 + Quick start safety note

Delete: none.

HOTFIXES: n/a — 신규 기능이지 deviation 아님. tasks/HOTFIXES.md 는 건드리지 않음.


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

// 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:

    /// 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
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:

[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:

//! `kebab reset` core — scope-driven path enumeration + wipe.
//!
//! The CLI (and any future TUI surface) calls `enumerate_paths(scope, &cfg)`
//! to compute exactly which on-disk paths the user has asked to remove,
//! presents that list for confirmation, then calls `execute(scope, &cfg)`
//! to actually remove them. Splitting the read step (enumerate) from the
//! write step (execute) is what lets the confirm UI show a faithful
//! preview without having to re-derive the path set.
//!
//! `--vector-only` additionally truncates `embedding_records` in SQLite
//! so the next `kebab ingest` re-embeds cleanly without orphan rows.

use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use kebab_config::{Config, expand_path};

/// What the user asked to remove. Mutually exclusive — picked by the CLI
/// from a clap `ArgGroup`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResetScope {
    /// Wipe config + data + cache + state (all four XDG dirs).
    All,
    /// Wipe data + cache + state. Config is preserved so the next run
    /// behaves the same. Default when the user passes `--data-only`.
    DataOnly,
    /// Wipe only the Lance vector_dir off-disk AND truncate the matching
    /// `embedding_records` rows in SQLite. Documents / chunks survive.
    VectorOnly,
    /// Wipe only the config dir.
    ConfigOnly,
}

/// Result of a successful wipe — emitted as `reset_report.v1` by the
/// CLI's `--json` mode and used by the human-mode summary line.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResetReport {
    pub scope: ResetScope,
    pub removed_paths: Vec<PathBuf>,
    pub embedding_rows_truncated: u64,
}

/// Compute the absolute on-disk paths a given scope will wipe, given a
/// loaded `Config`. Pure — does NOT touch the filesystem.
///
/// `--all` returns all four XDG paths in a stable order (config, data,
/// cache, state). `--vector-only` returns the resolved `storage.vector_dir`.
/// Order is preserved across calls so the confirm UI is deterministic.
pub fn enumerate_paths(scope: ResetScope, cfg: &Config) -> Vec<PathBuf> {
    let cfg_dir = Config::xdg_config_path()
        .parent()
        .map(PathBuf::from)
        .unwrap_or_default();
    let data_dir = Config::xdg_data_dir();
    let cache_dir = Config::xdg_cache_dir();
    let state_dir = Config::xdg_state_dir();

    match scope {
        ResetScope::All => vec![cfg_dir, data_dir, cache_dir, state_dir],
        ResetScope::DataOnly => vec![data_dir, cache_dir, state_dir],
        ResetScope::VectorOnly => {
            let vector_dir =
                expand_path(&cfg.storage.vector_dir, &data_dir.to_string_lossy());
            vec![vector_dir]
        }
        ResetScope::ConfigOnly => vec![cfg_dir],
    }
}

/// Best-effort byte size of a directory tree (returns 0 on any I/O error
/// — this is for the confirm UI, not accounting). Skips broken symlinks
/// instead of bubbling errors so a half-broken cache still gets summed.
pub fn estimate_size_bytes(paths: &[PathBuf]) -> u64 {
    fn walk(p: &std::path::Path) -> u64 {
        let mut total = 0u64;
        let entries = match std::fs::read_dir(p) {
            Ok(it) => it,
            Err(_) => return 0,
        };
        for e in entries.flatten() {
            let ft = match e.file_type() {
                Ok(t) => t,
                Err(_) => continue,
            };
            if ft.is_dir() {
                total += walk(&e.path());
            } else if ft.is_file() {
                total += e.metadata().map(|m| m.len()).unwrap_or(0);
            }
        }
        total
    }
    paths.iter().map(|p| walk(p)).sum()
}

/// Wipe every path from `enumerate_paths(scope, cfg)`. For
/// `ResetScope::VectorOnly`, also truncates the SQLite
/// `embedding_records` table so the store doesn't point at the Lance
/// rows we just removed off-disk.
///
/// Idempotent: a missing path is treated as already-removed (success).
/// Returns a `ResetReport` listing exactly what was removed (paths that
/// existed before the call) so `--json` callers see the truth, not the
/// request.
pub fn execute(scope: ResetScope, cfg: &Config) -> Result<ResetReport> {
    let paths = enumerate_paths(scope, cfg);
    let mut removed = Vec::new();

    for p in &paths {
        if !p.exists() {
            continue;
        }
        std::fs::remove_dir_all(p)
            .with_context(|| format!("remove {}", p.display()))?;
        removed.push(p.clone());
    }

    let embedding_rows_truncated = if matches!(scope, ResetScope::VectorOnly) {
        truncate_embeddings(cfg)?
    } else {
        0
    };

    Ok(ResetReport {
        scope,
        removed_paths: removed,
        embedding_rows_truncated,
    })
}

/// Open the SQLite store at the configured path and run
/// `truncate_embedding_records`. Returns the row count BEFORE truncation
/// so the wire report can surface it. If the SQLite file does not exist
/// (e.g. user has never ingested), returns 0 — not an error.
fn truncate_embeddings(cfg: &Config) -> Result<u64> {
    let data_dir = Config::xdg_data_dir();
    let sqlite_path =
        expand_path(&cfg.storage.sqlite, &data_dir.to_string_lossy());
    if !sqlite_path.exists() {
        return Ok(0);
    }
    let store = kebab_store_sqlite::SqliteStore::open(&sqlite_path)
        .context("open SqliteStore for truncate_embedding_records")?;

    // Count first so the report is meaningful.
    let before: i64 = {
        let conn = store.lock_conn();
        conn.query_row("SELECT COUNT(*) FROM embedding_records", [], |r| r.get(0))
            .unwrap_or(0)
    };
    store.truncate_embedding_records()?;
    Ok(u64::try_from(before).unwrap_or(0))
}

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

    fn cfg_with_vector_dir(s: &str) -> Config {
        let mut c = Config::defaults();
        c.storage.vector_dir = s.to_string();
        c
    }

    #[test]
    fn enumerate_data_only_excludes_config_dir() {
        let cfg = Config::defaults();
        let paths = enumerate_paths(ResetScope::DataOnly, &cfg);
        let cfg_dir = Config::xdg_config_path()
            .parent()
            .map(PathBuf::from)
            .unwrap_or_default();
        assert!(!paths.contains(&cfg_dir));
    }

    #[test]
    fn enumerate_vector_only_returns_resolved_vector_dir() {
        let cfg = cfg_with_vector_dir("{data_dir}/lancedb");
        let paths = enumerate_paths(ResetScope::VectorOnly, &cfg);
        assert_eq!(paths.len(), 1);
        let s = paths[0].to_string_lossy().into_owned();
        assert!(s.ends_with("/lancedb"), "got: {s}");
    }

    #[test]
    fn enumerate_all_has_four_distinct_paths() {
        let cfg = Config::defaults();
        let paths = enumerate_paths(ResetScope::All, &cfg);
        assert_eq!(paths.len(), 4);
        // Distinct — XDG layout puts each in its own subtree.
        let unique: std::collections::HashSet<_> = paths.iter().collect();
        assert_eq!(unique.len(), 4);
    }

    #[test]
    fn estimate_size_returns_zero_on_missing_dir() {
        assert_eq!(estimate_size_bytes(&[PathBuf::from("/nonexistent/xyz")]), 0);
    }

    #[test]
    fn execute_data_only_removes_dir_and_returns_report() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("kebab-data");
        std::fs::create_dir_all(target.join("inner")).unwrap();
        std::fs::write(target.join("inner/x"), b"y").unwrap();
        assert!(target.exists());

        // Drive `execute` with a synthetic enumerate result by overriding
        // the XDG env var so `xdg_data_dir()` returns our temp path.
        // The other three XDG dirs we point at fresh subdirs so the test
        // is self-contained.
        let _g_data = scoped_env("XDG_DATA_HOME", dir.path().join("data"));
        let _g_cfg = scoped_env("XDG_CONFIG_HOME", dir.path().join("cfg"));
        let _g_cache = scoped_env("XDG_CACHE_HOME", dir.path().join("cache"));
        let _g_state = scoped_env("XDG_STATE_HOME", dir.path().join("state"));
        std::fs::create_dir_all(dir.path().join("data/kebab")).unwrap();
        std::fs::write(dir.path().join("data/kebab/marker"), b"hi").unwrap();

        let cfg = Config::defaults();
        let report = execute(ResetScope::DataOnly, &cfg).unwrap();
        assert_eq!(report.scope, ResetScope::DataOnly);
        // data dir was the only one that actually existed → only one
        // entry in `removed_paths`.
        assert_eq!(report.removed_paths.len(), 1);
        assert!(!dir.path().join("data/kebab").exists());
    }

    /// Scoped env var setter that restores the previous value on drop.
    /// Tests run sequentially per binary by default, but we restore to
    /// be polite to anyone who switches `--test-threads`.
    fn scoped_env(key: &str, val: std::path::PathBuf) -> EnvGuard {
        let prev = std::env::var(key).ok();
        // SAFETY: tests in this module run single-threaded under the
        // default cargo test runner; this is the same pattern used in
        // `kebab-config::xdg_paths_honor_env`.
        unsafe { std::env::set_var(key, &val) };
        EnvGuard { key: key.to_string(), prev }
    }

    struct EnvGuard {
        key: String,
        prev: Option<String>,
    }

    impl Drop for EnvGuard {
        fn drop(&mut self) {
            unsafe {
                match self.prev.take() {
                    Some(v) => std::env::set_var(&self.key, v),
                    None => std::env::remove_var(&self.key),
                }
            }
        }
    }
}
  • Step 3: Wire the new module into the crate root

Edit crates/kebab-app/src/lib.rs. Find the existing mod declarations near the top of the file (search for pub mod doctor_signal) and add:

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
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

{
  "$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):

    #[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):

/// 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
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:

//! 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:

[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):

    /// 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:

        Cmd::Reset {
            all,
            data_only: _,
            vector_only,
            config_only,
            yes,
        } => {
            use kebab_app::ResetScope;
            // `--data-only` explicit OR no scope flag at all → DataOnly.
            // The `data_only: _` binding above is intentional — clap's
            // `group = "reset_scope"` already enforces mutual exclusion,
            // so the flag's presence does not change the resolved scope.
            let scope = if *all {
                ResetScope::All
            } else if *vector_only {
                ResetScope::VectorOnly
            } else if *config_only {
                ResetScope::ConfigOnly
            } else {
                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):

/// Minimal stdin/stdout confirm prompt. No new dep — uses stdlib
/// `IsTerminal`. Returns `Ok(true)` only when the user types y/Y/yes.
/// Empty input or anything else → false (safe default).
fn confirm_destructive(
    scope: kebab_app::ResetScope,
    paths: &[std::path::PathBuf],
    bytes: u64,
) -> anyhow::Result<bool> {
    use std::io::Write;
    let mut out = std::io::stderr().lock();
    writeln!(out, "kebab reset ({:?}): about to remove", scope)?;
    for p in paths {
        writeln!(out, "  - {}", p.display())?;
    }
    writeln!(out, "estimated total: {} bytes", bytes)?;
    write!(out, "Proceed? [y/N] ")?;
    out.flush()?;

    let mut line = String::new();
    std::io::stdin().read_line(&mut line)?;
    let s = line.trim().to_ascii_lowercase();
    Ok(matches!(s.as_str(), "y" | "yes"))
}
  • Step 6: Build the binary so the integration test can find it

Run: cargo build -p kebab-cli Expected: clean compile.

  • Step 7: Run integration tests

Run: cargo test -p kebab-cli --test reset_cli Expected: 3 PASS.

  • Step 8: Run the full crate test suite to confirm no regression

Run: cargo test -p kebab-cli Expected: full PASS.

  • Step 9: Commit
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:

| `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:

제거는 `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:

- 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
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: plannedstatus: 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:

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
git add tasks/p9/p9-fb-06-data-reset-command.md
git commit -m "chore(tasks): mark p9-fb-06 in_progress

Flips to \`completed\` once the PR merges (separate one-line commit so
spec history reflects reality)."
  • Step 5: PR with gitea-ops review loop

Per the project workflow rule (memory: feedback_pr_workflow.md):

  1. gitea-pr --title "feat(cli): kebab reset (p9-fb-06)" --head feat/p9-fb-06-reset --body <see below>
  2. gitea-pr-status <PR#> --wait-ci until gate passes.
  3. Review loop: gitea-pr-diff → analyze → gitea-pr-review (REQUEST_CHANGES → ... → APPROVE).
  4. APPROVE achieved → merge immediately, no asking. tea pr merge <PR#> (or Gitea API), pull main locally, delete branch, move on to the next task in the priority list (p9-fb-01 batch).

PR body template:

## Summary
- `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]`.
- TTY confirm prompt (stdin non-interactive without `--yes` → exit 2).
- `--vector-only` truncates SQLite `embedding_records` to keep the store consistent after the off-disk Lance dir is removed.
- Wire schema `reset_report.v1` for `--json` mode.

## Scope (p9-fb-06)
- spec: `tasks/p9/p9-fb-06-data-reset-command.md`
- feedback origin: `tasks/p9/p9-dogfooding-feedback.md` item 4

## Test plan
- [x] `cargo test -p kebab-store-sqlite --test truncate_embeddings`
- [x] `cargo test -p kebab-app reset`
- [x] `cargo test -p kebab-cli --test reset_cli`
- [x] `cargo clippy --all-targets -- -D warnings` (touched crates)

Self-review

Spec coverage (against tasks/p9/p9-fb-06-data-reset-command.md):

  • Public surface kebab reset [--all|--data-only|--vector-only|--config-only] [--yes] → Task 4.
  • Default = --data-only → Task 4 handler (else { ResetScope::DataOnly }).
  • --config <path> honored → Task 4 (Config::load(cli.config.as_deref())). The behavior here matters more for --vector-only (which reads cfg.storage.vector_dir). For the XDG-rooted scopes, the path resolution goes through Config::xdg_* env-aware methods — same effective honor since the user can set XDG_*_HOME in the same shell.
  • Confirm prompt with paths + bytes + (y/N) → Task 4 confirm_destructive.
  • Non-TTY without --yes → abort with hint → Task 4 handler + integration test 2.
  • --vector-only truncates embedding_records → Task 1 + Task 2.
  • No auto kebab init → Task 4 handler emits a hint instead.
  • Test plan items (path estimation unit / data-only integration / vector-only integration) → Tasks 2 + 4.
  • DoD: cargo test -p kebab-cli PASS → Task 6. README updated → Task 5. --help "irreversible" → Task 4 doc-comment on the Reset variant.

One spec note we did NOT implement separately: vector-only integration test — Task 4 covers data-only + non-tty + json. The vector-only path is exercised by Task 2's truncate_embeddings unit test (does the wipe work?) and by the enumerate_vector_only_returns_resolved_vector_dir unit test (does enumeration return the right dir?). A full end-to-end vector-only integration test would require seeding both a Lance dir and embedding rows under a tempdir XDG layout — feasible but long, and the unit-level coverage already proves both halves. If the reviewer pushes back, add it as a follow-up commit in the same PR.

Placeholder scan: none — every step has the actual code or command.

Type consistency: ResetScope / ResetReport defined in Task 2, used unchanged in Tasks 3, 4. truncate_embedding_records returns Result<()> in Task 1, called in Task 2 via the wrapper that adds row count. Names match across tasks.


Execution Handoff

Plan saved to docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md.

Auto mode active + caveman feedback rule: proceed with inline execution (executing-plans skill), no need to ask. Reasoning:

  • 6 tasks, all touch crates I already know.
  • Each task has tests that run in seconds — feedback loop tight enough that a fresh subagent per task would be overhead.
  • PR merge follows immediately after task 6 per the updated workflow rule (no human gate between APPROVE and merge).

Starting executing-plans next.