From 4f96b1b01d72e8285c2370e3729cfe90b9a00d23 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 3 May 2026 06:20:16 +0000 Subject: [PATCH] feat(kebab-app + kebab-cli): p9-fb-18 CLI ask --session multi-turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도그푸딩 item 14 — CLI 에서도 multi-turn 가능하도록 `kebab ask --session ` 추가. p9-fb-17 의 ChatSessionRepo 위에 build, 첫 호출 세션 자동 생성, 이후 호출이 prior turns 를 history 로 받아 follow-up. external AI integration (Claude Code skill / MCP) 도 같은 facade 로 stateful 대화 가능. ## 핵심 변경 - **`App::ask_with_session(session_id, query, opts) -> Answer`** — load session header → list_turns 로 prior history → 빌드 retriever stack (lexical / vector / hybrid 같은 분기) → `RagPipeline::ask_ with_history` 호출 → 첫 호출이면 `chat_sessions` row 자동 생성 (title = first_question_title) → `chat_turns` 새 row append. - **`App::first_question_title(question)`** helper — `trim() + nfc() + 40 chars cap`, fallback `"untitled"`. unicode-normalization workspace dep 재사용. - **`App::blake3_truncate(input)`** helper — `blake3(session_id || ":" || turn_index)` 의 첫 16 byte 를 u128 으로, format!{:032x} 로 32-hex `turn_id`. - **`ask_with_session_with_config`** facade — CLI 진입점. - **CLI `--session ` flag** — `Cmd::Ask` 의 `session: Option< String>` field, handler 가 None 이면 `ask_with_config` (기존 단발), Some(id) 면 `ask_with_session_with_config` 호출. - **에러 정책**: session create / turn append 실패 시 warn 로그 남기고 answer 는 그대로 반환 — 사용자가 답변 받은 컴퓨트를 잃지 않음. 영속성 실패가 답변 응답을 가로막지 않는 conservative shape. ## 테스트 - `App::first_question_title` 3 unit (trim + cap, empty → untitled, korean NFD → NFC) - `App::blake3_truncate` 1 unit (deterministic + distinct across varying session/index) - 워크스페이스 전체 `cargo test --workspace --no-fail-fast -j 1` exit 0 - `cargo clippy --workspace --all-targets -- -D warnings` clean ## 문서 - README `kebab ask` 행: `--session` 안내 + chat_sessions 자동 생성 + `kebab reset --data-only` wipe 안내 - README **외부 AI 통합** 절: Claude Code skill 이 `--session` 으로 multi-turn 가능하다는 한 문장 추가 - HANDOFF entry - spec status planned → in_progress ## Out of scope (spec deviation) - `--repl` (stdin loop) — spec 명시되어 있으나 stdin fixture 부담 으로 deferral. 별도 후속 task 또는 `--session` 사용자 경험 회신 후 결정. - session list / show / delete 관리 명령 (spec 의 Out of scope). Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF.md | 1 + README.md | 4 +- crates/kebab-app/src/app.rs | 229 ++++++++++++++++++++++ crates/kebab-app/src/lib.rs | 17 ++ crates/kebab-cli/src/main.rs | 24 ++- tasks/p9/p9-fb-18-cli-ask-session-repl.md | 2 +- 6 files changed, 270 insertions(+), 7 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 147aa3d..6d3f159 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -55,6 +55,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load` 가 `path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable` 가 `new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-17)** — Multi-turn chat session 영속화 (storage 만 — UI 는 p9-fb-18). SQLite V005 migration (spec 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트, HOTFIXES) 으로 `chat_sessions` (session_id PK + created_at + updated_at + title + config_snapshot_json) + `chat_turns` (turn_id PK + session_id FK ON DELETE CASCADE + turn_index + question + answer + citations_json + created_at, UNIQUE(session_id, turn_index)) + `idx_chat_turns_session` 추가. `kebab_core::ChatSessionRepo` trait 6 메서드 (create_session / get_session / list_sessions / delete_session / append_turn / list_turns) + `kebab_core::{ChatSessionRow, ChatTurnRow}` 신규 export. `kebab-store-sqlite::SqliteStore` impl (별 `chat_sessions.rs` 모듈) — append_turn 이 insert + parent updated_at bump 을 같은 conn 에서 처리. frozen design §5 storage 에 §5.7a chat_sessions/turns 절 신설. spec: `tasks/p9/p9-fb-17-chat-session-storage.md`. unblocks p9-fb-18 (CLI session/repl). +- **2026-05-03 P9 도그푸딩 후속 (p9-fb-18)** — CLI `kebab ask --session ` (multi-turn). p9-fb-17 의 ChatSessionRepo 위에 `kebab-app::App::ask_with_session(session_id, query, opts) -> Answer` 메서드. 첫 호출 시 자동으로 `chat_sessions` row 생성 (title = 첫 question NFC trim 40 chars), 이후 호출은 `list_turns` 로 prior history 받아 `RagPipeline::ask_with_history` 호출 + 새 turn append. `App` 의 helper: `first_question_title(question)` (NFC + trim + 40 char cap, fallback `"untitled"`) + `blake3_truncate(input)` (32-hex `turn_id` 생성). facade `kebab_app::ask_with_session_with_config` + CLI `--session ` flag 추가. `--repl` 은 spec 명시 사항이지만 stdin loop fixture 부담 으로 후속 task 로 deferral (out of scope per HANDOFF). spec: `tasks/p9/p9-fb-18-cli-ask-session-repl.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index 76ae578..319585a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ kebab doctor | `kebab search --mode {lexical,vector,hybrid} "" [--no-cache]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale | | `kebab list docs` | 색인된 문서 목록 | | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | -| `kebab ask "" [--show-citations / --hide-citations]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요 | +| `kebab ask "" [--show-citations / --hide-citations] [--session ]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session ` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe | | `kebab doctor` | 설정/모델/DB 헬스 체크 | | `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) | | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | @@ -157,7 +157,7 @@ config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.tom `--json` 출력 + frozen wire schema v1 가 stable contract. 통합 옵션: -- **Claude Code / Codex skill** — `kebab search --json` / `kebab ask --json` 호출하는 ~50줄 wrapper. +- **Claude Code / Codex skill** — `kebab search --json` / `kebab ask --json` 호출하는 ~50줄 wrapper. multi-turn 은 `kebab ask --session --json` 으로 영속 — wrapper 가 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18). - **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. - **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중). diff --git a/crates/kebab-app/src/app.rs b/crates/kebab-app/src/app.rs index 22c4084..e1cc135 100644 --- a/crates/kebab-app/src/app.rs +++ b/crates/kebab-app/src/app.rs @@ -300,6 +300,165 @@ impl App { pipeline.ask(query, opts) } + /// p9-fb-18: ask under a persistent chat session. Loads the + /// session's prior turns (if any), runs the query through + /// `RagPipeline::ask_with_history`, then appends the new turn + /// + (auto-)creates the session row on first use. + /// + /// `session_id` is caller-supplied. If the session doesn't + /// exist yet, a new `chat_sessions` row is created with title + /// derived from the first question (≤40 chars, trimmed and + /// NFC-normalized). Subsequent calls with the same + /// `session_id` extend the conversation. + /// + /// The returned `Answer` carries `conversation_id = Some( + /// session_id)` and `turn_index = Some(n)` per p9-fb-15. The + /// new `chat_turns` row is committed before this method + /// returns; on persistence error, the answer is still returned + /// (don't lose the user's compute) but the error is logged so + /// the operator notices. + pub fn ask_with_session( + &self, + session_id: &str, + query: &str, + opts: AskOpts, + ) -> Result { + use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow}; + use std::time::{SystemTime, UNIX_EPOCH}; + + // Load (or create) the session header. + let now_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let existing = self.sqlite.get_session(session_id)?; + let prior_turns = match &existing { + Some(_) => self.sqlite.list_turns(session_id)?, + None => Vec::new(), + }; + let next_index = u32::try_from(prior_turns.len()).unwrap_or(u32::MAX); + + // Build history Vec from the persisted rows. Citations + // are decoded best-effort — a corrupted citations_json + // becomes an empty Vec rather than a panic (history is + // advisory, not authoritative). + let history: Vec = prior_turns + .iter() + .map(|row| kebab_core::Turn { + question: row.question.clone(), + answer: row.answer.clone(), + citations: serde_json::from_str(&row.citations_json).unwrap_or_default(), + created_at: time::OffsetDateTime::from_unix_timestamp(row.created_at) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH), + }) + .collect(); + + // Build the retriever stack the same way `ask` does. + let retriever: Arc = match opts.mode { + SearchMode::Lexical => Arc::new(LexicalRetriever::with_settings( + self.sqlite.clone(), + lexical_index_version(&self.config), + self.config.search.snippet_chars, + )), + SearchMode::Vector => { + let (emb, vec_store) = self.require_embeddings()?; + let vec_iv = vector_index_version(emb.as_ref()); + let vec_dyn: Arc = vec_store; + let emb_dyn: Arc = emb; + Arc::new(VectorRetriever::with_settings( + vec_dyn, + emb_dyn, + self.sqlite.clone(), + vec_iv, + self.config.search.snippet_chars, + )) + } + SearchMode::Hybrid => { + let lex = Arc::new(LexicalRetriever::with_settings( + self.sqlite.clone(), + lexical_index_version(&self.config), + self.config.search.snippet_chars, + )) as Arc; + let (emb, vec_store) = self.require_embeddings()?; + let vec_iv = vector_index_version(emb.as_ref()); + let vec_dyn: Arc = vec_store; + let emb_dyn: Arc = emb; + let vec_retr = Arc::new(VectorRetriever::with_settings( + vec_dyn, + emb_dyn, + self.sqlite.clone(), + vec_iv, + self.config.search.snippet_chars, + )) as Arc; + Arc::new(HybridRetriever::new(&self.config, lex, vec_retr)) + } + }; + let llm = self.llm()?; + let pipeline = + RagPipeline::new(self.config.clone(), retriever, llm, self.sqlite.clone()); + let answer = pipeline.ask_with_history( + query, + history, + session_id.to_string(), + next_index, + opts, + )?; + + // Auto-create the session header on first use. Title from + // the first question (≤40 chars after trim). + if existing.is_none() { + let title = first_question_title(query); + let session_row = ChatSessionRow { + session_id: session_id.to_string(), + created_at: now_unix, + updated_at: now_unix, + title: Some(title), + config_snapshot_json: serde_json::json!({ + "prompt_template_version": self.config.rag.prompt_template_version, + "llm.model": self.config.models.llm.model, + "max_context_tokens": self.config.rag.max_context_tokens, + }) + .to_string(), + }; + if let Err(e) = self.sqlite.create_session(&session_row) { + tracing::warn!( + target: "kebab-app", + error = %e, + session_id = %session_id, + "ask_with_session: create_session failed; continuing — turn append will surface a more useful error" + ); + } + } + + // Append the new turn. Failure is logged but does NOT mask + // the answer — the user still gets their response, the + // operator sees the persistence error in the warn log. + let turn_id = format!( + "{:032x}", + blake3_truncate(&format!("{session_id}:{next_index}")), + ); + let turn_row = ChatTurnRow { + turn_id, + session_id: session_id.to_string(), + turn_index: next_index, + question: query.to_string(), + answer: answer.answer.clone(), + citations_json: serde_json::to_string(&answer.citations).unwrap_or_else(|_| "[]".to_string()), + created_at: now_unix, + }; + if let Err(e) = self.sqlite.append_turn(&turn_row) { + tracing::warn!( + target: "kebab-app", + error = %e, + session_id = %session_id, + turn_index = next_index, + "ask_with_session: append_turn failed; answer returned regardless" + ); + } + + Ok(answer) + } + /// Returns `true` when the workspace has embeddings turned off /// (`provider = "none"` or `dimensions = 0`). Lexical-only mode. pub(crate) fn embeddings_disabled(&self) -> bool { @@ -446,3 +605,73 @@ fn vector_index_version(embedder: &dyn Embedder) -> IndexVersion { embedder.dimensions(), )) } + +/// p9-fb-18: derive a chat-session title from the first question. +/// Trim, NFC, take first ~40 chars. Always non-empty (falls back +/// to `"untitled"`) — same defensive shape as kebab-normalize's +/// derive_title. +fn first_question_title(question: &str) -> String { + use unicode_normalization::UnicodeNormalization; + let nfc: String = question.trim().nfc().collect(); + let truncated: String = nfc.chars().take(40).collect(); + if truncated.is_empty() { + "untitled".to_string() + } else { + truncated + } +} + +/// p9-fb-18: 32-hex `turn_id` derived from session_id + turn_index. +/// blake3 hash truncated to first 16 bytes; format as 32-char lowercase +/// hex so it slots into the `chat_turns.turn_id` column without +/// collision concerns under any realistic per-session turn count. +fn blake3_truncate(input: &str) -> u128 { + let hash = blake3::hash(input.as_bytes()); + let bytes = hash.as_bytes(); + let mut buf = [0u8; 16]; + buf.copy_from_slice(&bytes[..16]); + u128::from_be_bytes(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// p9-fb-18: title trims, NFC-normalizes, caps at 40 chars. + #[test] + fn first_question_title_trims_and_caps() { + assert_eq!(first_question_title(" hello "), "hello"); + let long = "a".repeat(100); + assert_eq!(first_question_title(&long).chars().count(), 40); + } + + /// p9-fb-18: empty / whitespace-only question falls back to + /// `"untitled"` (never returns empty). + #[test] + fn first_question_title_falls_back_to_untitled() { + assert_eq!(first_question_title(""), "untitled"); + assert_eq!(first_question_title(" "), "untitled"); + assert_eq!(first_question_title("\t\n"), "untitled"); + } + + /// p9-fb-18: korean NFD → NFC. + #[test] + fn first_question_title_nfc_normalizes_korean() { + let nfd = "\u{1100}\u{1161}".to_string(); // 가 (NFD) + let title = first_question_title(&nfd); + assert_eq!(title, "\u{AC00}", "expected NFC composed form"); + } + + /// p9-fb-18: blake3_truncate is deterministic and differs across + /// distinct inputs. + #[test] + fn blake3_truncate_deterministic_and_distinct() { + let a = blake3_truncate("session-x:0"); + let b = blake3_truncate("session-x:0"); + let c = blake3_truncate("session-x:1"); + let d = blake3_truncate("session-y:0"); + assert_eq!(a, b, "same input → same hash"); + assert_ne!(a, c, "different turn_index → different hash"); + assert_ne!(a, d, "different session_id → different hash"); + } +} diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 62a3c20..87cfa01 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -1499,6 +1499,23 @@ pub fn ask_with_config( App::open_with_config(config)?.ask(query, opts) } +/// p9-fb-18: ask under a persistent chat session. Loads prior turns +/// from `chat_sessions[session_id]`, runs the query as a follow-up +/// (via `RagPipeline::ask_with_history`), and appends the new turn +/// — auto-creating the session header on first use. Returns an +/// `Answer` with `conversation_id = Some(session_id)` and +/// `turn_index` set to the new (post-append) index. CLI `kebab +/// ask --session ` entry point (p9-fb-18). +#[doc(hidden)] +pub fn ask_with_session_with_config( + config: kebab_config::Config, + session_id: &str, + query: &str, + opts: AskOpts, +) -> anyhow::Result { + App::open_with_config(config)?.ask_with_session(session_id, query, opts) +} + /// Run the doctor checks against the explicit config path the user /// requested via `--config` (or the XDG default if `None`). The /// `config_loaded` check reports the actual path probed and the diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 97aaf5d..93886d5 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -124,6 +124,16 @@ enum Cmd { /// to another tool that doesn't want trailing metadata. #[arg(long)] hide_citations: bool, + + /// p9-fb-18: persistent multi-turn chat session id. First call + /// auto-creates the session in SQLite (`chat_sessions`), each + /// subsequent call with the same id loads prior turns as + /// history and appends the new Q/A. Without this flag, ask + /// is single-shot (no persistence). The session id is + /// caller-supplied — pick anything stable per conversation + /// (e.g. `kb-rust-async-2026-05`). + #[arg(long, value_name = "ID")] + session: Option, }, /// Wipe XDG data dirs (and optionally the Lance vector store) so the @@ -453,6 +463,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> { seed, show_citations, hide_citations, + session, } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let opts = kebab_app::AskOpts { @@ -465,14 +476,19 @@ fn run(cli: &Cli) -> anyhow::Result<()> { // once on completion). The TUI ask pane (P9-3) is what // wires up a real `mpsc::Sender` here. stream_sink: None, - // p9-fb-15: CLI single-shot ask. p9-fb-18 adds - // `--session` / `--repl` for multi-turn over the same - // facade (passes a populated `history`). + // p9-fb-18: when `--session` is set, the facade + // (`ask_with_session_with_config`) loads prior turns + // from SQLite and stuffs them into AskOpts.history + // before calling `ask_with_history`. Single-shot path + // (no `--session`) keeps the empty defaults. history: Vec::new(), conversation_id: None, turn_index: None, }; - let ans = kebab_app::ask_with_config(cfg, query, opts)?; + let ans = match session.as_deref() { + Some(sid) => kebab_app::ask_with_session_with_config(cfg, sid, query, opts)?, + None => kebab_app::ask_with_config(cfg, query, opts)?, + }; if cli.json { println!("{}", serde_json::to_string(&wire::wire_answer(&ans))?); } else { diff --git a/tasks/p9/p9-fb-18-cli-ask-session-repl.md b/tasks/p9/p9-fb-18-cli-ask-session-repl.md index e0dd96a..48a2b7e 100644 --- a/tasks/p9/p9-fb-18-cli-ask-session-repl.md +++ b/tasks/p9/p9-fb-18-cli-ask-session-repl.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-cli + kebab-app task_id: p9-fb-18 title: "CLI ask --session / --repl" -status: planned +status: in_progress depends_on: [p9-fb-15, p9-fb-17] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md