diff --git a/HANDOFF.md b/HANDOFF.md index 385b639..ef59297 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -42,6 +42,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **P9-4 enter_inspect helper + Search `i` 키** — spec 의 진입 경로 (Library Enter → Doc inspect, Search `i` → Chunk inspect) 를 한 helper 로 묶음. `InspectTarget` enum (`Doc(DocumentId) | Chunk(ChunkId)`), `return_to: Pane` 가 Esc 시 원래 pane 으로 복귀. `c` 키가 모든 section (metadata / provenance / blocks / spans / text / embeddings) 일괄 collapse/expand — spec 의 \"focus 기반 selective collapse\" 는 v1 단순화. - **2026-05-02 P9 도그푸딩 후속 (p9-fb-06)** — `kebab reset --all|--data-only|--vector-only|--config-only [--yes]` 추가. TTY 가 아니면 `--yes` 필수 (silent destruction 금지). `--vector-only` 가 SQLite `embedding_records` 도 함께 truncate (off-disk Lance dir 만 wipe 시 orphan 방지). 도그푸딩 막힘 강도 1위 (수동 4 경로 `rm -rf` 부담) 해소. spec: `tasks/p9/p9-fb-06-data-reset-command.md`, plan: `docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md`. - **2026-05-02 P9 도그푸딩 후속 (spec PR #51 + p9-fb-01 + p9-fb-02)** — `kebab ingest` 진행 표시 도입. frozen design §2.4a 신설 (wire schema `ingest_progress.v1` line-delimited streaming) + §10 의 long-running 작업 절 추가. `kebab-app::ingest_with_config_progress(.., progress: Option>)` facade 추가, 기존 `_with_config` 가 `progress=None` forwarding wrapper. CLI 가 indicatif TTY 진행 바 (stderr) / non-TTY 한 줄씩 / `--json` 모드는 line-delimited stdout. p9-fb-03 (TUI background worker) + p9-fb-04 (cancel) 가 같은 stream 위에 build. +- **2026-05-02 P9 도그푸딩 후속 (p9-fb-03)** — TUI 의 background ingest worker. Library 의 `r` 키가 `kebab_app::ingest_with_config_progress` 를 spawned thread 에서 호출, run loop 가 매 frame 마다 progress channel drain → 화면 하단 status bar 1 줄 갱신. terminal event (`Completed`/`Aborted`) 후 3 초 final 라인 hold + 자동 hide + Library auto-refresh. `IngestState` 의 `cancel_tx: Sender<()>` slot 만 정의 (p9-fb-04 가 wire). spec: `tasks/p9/p9-fb-03-tui-ingest-background.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index bea76bc..6a41b5d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask ""` | RAG 답변 + 근거 인용. 근거 부족 시 거절. Ollama 필요 | | `kebab doctor` | 설정/모델/DB 헬스 체크 | -| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중) | +| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide | | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index f7294d6..1791ce8 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -166,6 +166,46 @@ impl Default for InspectState { } } +/// Background-ingest state — owned by p9-fb-03. +/// +/// The TUI lets the user fire `kebab ingest` from inside the shell +/// without blocking the event loop. Pressing `r` on the Library pane +/// spawns a worker thread that calls +/// `kebab_app::ingest_with_config_progress(.., Some(tx))`; the run +/// loop drains `rx` once per frame and updates the visible status +/// bar. When the worker thread joins (Sender dropped → `recv()` Err), +/// the final aggregate counts stay on screen for a few seconds and +/// then the slot clears. +/// +/// `cancel_tx` is reserved for `p9-fb-04` (Ctrl-C / Esc cancel +/// wiring); this task allocates the channel but never sends on it. +pub struct IngestState { + pub rx: std::sync::mpsc::Receiver, + pub counts: kebab_app::AggregateCounts, + pub current_path: Option, + pub current_idx: u32, + pub started_at: std::time::Instant, + /// `Some(_)` once a `Completed` or `Aborted` event has arrived; + /// the run loop holds the final line on screen for + /// `TERMINAL_LINE_HOLD_SECS` seconds and then clears the slot. + pub terminal_at: Option, + /// True when the terminal event was `Aborted` (vs `Completed`). + /// Used to colour the final line. + pub aborted: bool, + /// Worker thread handle. `take()`n at clear time so the join + /// happens after the user has had time to read the final line. + pub thread: Option>>, + /// Cancel-token slot for `p9-fb-04`. Defined here so that task + /// can wire `Esc` / `Ctrl-C` without restructuring `IngestState`. + /// This task never sends on it. + pub cancel_tx: std::sync::mpsc::Sender<()>, +} + +/// Seconds the final ingest status line stays on screen after a run +/// completes / aborts. After this elapses the run loop clears +/// `App.ingest_state` so the footer returns to the standard hints. +pub const TERMINAL_LINE_HOLD_SECS: u64 = 3; + /// TUI application. The shell that p9-1 stands up; later p9-* tasks /// add panes by populating their `Option<*State>` slot. pub struct App { @@ -178,6 +218,10 @@ pub struct App { pub ask: Option, /// Populated by p9-4. pub inspect: Option, + /// Populated by p9-fb-03 when the user kicks off an in-shell + /// ingest (Library `r`). Cleared by the run loop a few seconds + /// after the run reaches a terminal event. + pub ingest_state: Option, /// In-flight error overlay (popup); `Some` when the last facade /// call returned `Err` and the user has not dismissed yet. pub(crate) error_overlay: Option, @@ -199,6 +243,7 @@ impl App { search: None, ask: None, inspect: None, + ingest_state: None, error_overlay: None, should_quit: false, }) diff --git a/crates/kebab-tui/src/ingest_progress.rs b/crates/kebab-tui/src/ingest_progress.rs new file mode 100644 index 0000000..1294423 --- /dev/null +++ b/crates/kebab-tui/src/ingest_progress.rs @@ -0,0 +1,364 @@ +//! TUI background-ingest worker + status-bar reducer (p9-fb-03). +//! +//! The Library pane's `r` key fires `start_ingest`, which spawns a +//! worker thread calling +//! `kebab_app::ingest_with_config_progress(.., Some(tx))`. The run +//! loop drains the matching `rx` once per frame via +//! `drain_progress` and re-renders the status bar from the +//! accumulated counts. When the worker emits a terminal event +//! (`Completed` / `Aborted`) the status line freezes for a few +//! seconds (`TERMINAL_LINE_HOLD_SECS`) and then `tick_clear` returns +//! true so the run loop can drop the slot. +//! +//! `cancel_tx` is allocated here but never sent on — the cancel +//! wiring (`Esc` / `Ctrl-C`) lands in `p9-fb-04`. The slot exists +//! today so this task does not have to reshape `IngestState` later. + +use std::sync::mpsc; +use std::thread; + +use kebab_app::IngestEvent; +use kebab_core::SourceScope; + +use crate::app::{App, IngestState, TERMINAL_LINE_HOLD_SECS}; + +/// Already-running guard. Returns `Err` if `app.ingest_state` is +/// already populated — pressing `r` twice in a row should not spawn +/// two parallel workers (SQLite is mutexed but Lance writes can race +/// each other). +pub fn start_ingest(app: &mut App) -> anyhow::Result<()> { + if app.ingest_state.is_some() { + anyhow::bail!("ingest already running"); + } + let cfg = app.config.clone(); + let scope = SourceScope { + root: std::path::PathBuf::from(&cfg.workspace.root), + include: cfg.workspace.include.clone(), + exclude: cfg.workspace.exclude.clone(), + }; + let (tx, rx) = mpsc::channel::(); + let (cancel_tx, _cancel_rx) = mpsc::channel::<()>(); + // _cancel_rx is intentionally dropped here; p9-fb-04 will wire it + // through to `ingest_with_config_cancellable` (or equivalent). + // Holding both ends today keeps the channel allocated without + // doing anything with it. + let cfg_for_thread = cfg; + let thread = thread::spawn(move || { + kebab_app::ingest_with_config_progress(cfg_for_thread, scope, true, Some(tx)) + }); + app.ingest_state = Some(IngestState { + rx, + counts: kebab_app::AggregateCounts::default(), + current_path: None, + current_idx: 0, + started_at: std::time::Instant::now(), + terminal_at: None, + aborted: false, + thread: Some(thread), + cancel_tx, + }); + Ok(()) +} + +/// Drain whatever progress events have arrived since the last tick. +/// Non-blocking. Caller (the run loop) calls this once per frame. +/// +/// On a terminal event (`Completed` / `Aborted`) the function records +/// `terminal_at = Instant::now()` so subsequent ticks can decide when +/// to clear the slot. +pub fn drain_progress(app: &mut App) { + let Some(state) = app.ingest_state.as_mut() else { + return; + }; + while let Ok(event) = state.rx.try_recv() { + apply_event(state, event); + } +} + +fn apply_event(state: &mut IngestState, event: IngestEvent) { + match event { + IngestEvent::ScanStarted { .. } => { + // No counter to update; `started_at` already set by + // `start_ingest`. The status line shows "scanning…" while + // counts.scanned is zero. + } + IngestEvent::ScanCompleted { total } => { + state.counts.scanned = total; + } + IngestEvent::AssetStarted { idx, path, .. } => { + state.current_idx = idx; + state.current_path = Some(path); + } + IngestEvent::AssetFinished { + result, chunks, .. + } => { + // Per-asset counter increments mirror the way + // `kebab-app::ingest_with_config_progress` aggregates the + // final report — kept in sync so the status bar's running + // totals match the eventual `Completed { counts }`. + match result { + kebab_core::IngestItemKind::New => { + state.counts.new = state.counts.new.saturating_add(1); + state.counts.chunks_indexed = + state.counts.chunks_indexed.saturating_add(chunks); + } + kebab_core::IngestItemKind::Updated => { + state.counts.updated = state.counts.updated.saturating_add(1); + state.counts.chunks_indexed = + state.counts.chunks_indexed.saturating_add(chunks); + } + kebab_core::IngestItemKind::Skipped => { + state.counts.skipped = state.counts.skipped.saturating_add(1); + } + kebab_core::IngestItemKind::Error => { + state.counts.errors = state.counts.errors.saturating_add(1); + } + } + } + IngestEvent::Completed { counts } => { + // Trust the facade's authoritative aggregate — replaces + // any tiny drift between our running totals and the + // final report. + state.counts = counts; + state.current_path = None; + state.terminal_at = Some(std::time::Instant::now()); + state.aborted = false; + } + IngestEvent::Aborted { counts } => { + state.counts = counts; + state.current_path = None; + state.terminal_at = Some(std::time::Instant::now()); + state.aborted = true; + } + } +} + +/// Should the run loop drop `app.ingest_state` now? True when the +/// terminal event arrived ≥ `TERMINAL_LINE_HOLD_SECS` ago. +pub fn ready_to_clear(state: &IngestState) -> bool { + match state.terminal_at { + Some(t) => t.elapsed().as_secs() >= TERMINAL_LINE_HOLD_SECS, + None => false, + } +} + +/// Render the status-bar text for the current `IngestState`. Pure — +/// the run loop wraps this in a Paragraph widget. Returns the +/// human-friendly line per spec §p9-fb-03 ("`ingest: 142/1024 (14%) +/// parsing notes/foo.md [0:42]`"). +pub fn status_line(state: &IngestState) -> String { + if state.terminal_at.is_some() { + let elapsed = state.started_at.elapsed(); + let secs = elapsed.as_secs(); + if state.aborted { + return format!( + "✗ ingest aborted at {}/{} after {}s (new={} updated={} skipped={} errors={})", + state.counts.scanned.saturating_sub(state.counts.errors), + state.counts.scanned, + secs, + state.counts.new, + state.counts.updated, + state.counts.skipped, + state.counts.errors, + ); + } + return format!( + "✓ ingest: {} docs ({} new, {} updated, {} skipped), {} chunks indexed in {}s", + state.counts.scanned, + state.counts.new, + state.counts.updated, + state.counts.skipped, + state.counts.chunks_indexed, + secs, + ); + } + if state.counts.scanned == 0 { + let secs = state.started_at.elapsed().as_secs(); + return format!("ingest: scanning… [{}s]", secs); + } + let pct = (state.current_idx as u64).saturating_mul(100) / state.counts.scanned.max(1) as u64; + let elapsed = state.started_at.elapsed(); + let mm = elapsed.as_secs() / 60; + let ss = elapsed.as_secs() % 60; + let path = state.current_path.as_deref().unwrap_or("…"); + format!( + "ingest: {}/{} ({}%) {} [{}:{:02}]", + state.current_idx, state.counts.scanned, pct, path, mm, ss, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use kebab_app::AggregateCounts; + use kebab_core::IngestItemKind; + use std::sync::mpsc; + + fn fresh_state() -> IngestState { + let (_tx, rx) = mpsc::channel::(); + let (cancel_tx, _cancel_rx) = mpsc::channel::<()>(); + IngestState { + rx, + counts: AggregateCounts::default(), + current_path: None, + current_idx: 0, + started_at: std::time::Instant::now(), + terminal_at: None, + aborted: false, + thread: None, + cancel_tx, + } + } + + #[test] + fn apply_scan_completed_sets_total() { + let mut s = fresh_state(); + apply_event(&mut s, IngestEvent::ScanCompleted { total: 42 }); + assert_eq!(s.counts.scanned, 42); + } + + #[test] + fn apply_asset_finished_accumulates_per_kind_counters() { + let mut s = fresh_state(); + apply_event( + &mut s, + IngestEvent::AssetFinished { + idx: 1, + total: 3, + result: IngestItemKind::New, + chunks: 5, + }, + ); + apply_event( + &mut s, + IngestEvent::AssetFinished { + idx: 2, + total: 3, + result: IngestItemKind::Updated, + chunks: 2, + }, + ); + apply_event( + &mut s, + IngestEvent::AssetFinished { + idx: 3, + total: 3, + result: IngestItemKind::Skipped, + chunks: 0, + }, + ); + assert_eq!(s.counts.new, 1); + assert_eq!(s.counts.updated, 1); + assert_eq!(s.counts.skipped, 1); + assert_eq!(s.counts.chunks_indexed, 7); + } + + #[test] + fn apply_completed_replaces_counts_and_marks_terminal() { + let mut s = fresh_state(); + let final_counts = AggregateCounts { + scanned: 10, + new: 5, + updated: 5, + chunks_indexed: 50, + ..Default::default() + }; + apply_event(&mut s, IngestEvent::Completed { counts: final_counts }); + assert_eq!(s.counts, final_counts); + assert!(s.terminal_at.is_some()); + assert!(!s.aborted); + } + + #[test] + fn apply_aborted_marks_aborted_flag() { + let mut s = fresh_state(); + apply_event( + &mut s, + IngestEvent::Aborted { + counts: AggregateCounts::default(), + }, + ); + assert!(s.terminal_at.is_some()); + assert!(s.aborted); + } + + #[test] + fn status_line_scanning_shows_dots() { + let s = fresh_state(); + let line = status_line(&s); + assert!(line.starts_with("ingest: scanning…"), "got: {line}"); + } + + #[test] + fn status_line_in_progress_shows_count_path_pct() { + let mut s = fresh_state(); + apply_event(&mut s, IngestEvent::ScanCompleted { total: 100 }); + apply_event( + &mut s, + IngestEvent::AssetStarted { + idx: 14, + total: 100, + path: "notes/foo.md".into(), + media: "markdown".into(), + }, + ); + let line = status_line(&s); + assert!(line.contains("14/100"), "got: {line}"); + assert!(line.contains("(14%)"), "got: {line}"); + assert!(line.contains("notes/foo.md"), "got: {line}"); + } + + #[test] + fn status_line_terminal_completed_shows_check_mark_and_totals() { + let mut s = fresh_state(); + apply_event( + &mut s, + IngestEvent::Completed { + counts: AggregateCounts { + scanned: 10, + new: 8, + updated: 1, + skipped: 1, + chunks_indexed: 50, + ..Default::default() + }, + }, + ); + let line = status_line(&s); + assert!(line.starts_with("✓ ingest:"), "got: {line}"); + assert!(line.contains("10 docs"), "got: {line}"); + assert!(line.contains("50 chunks"), "got: {line}"); + } + + #[test] + fn status_line_terminal_aborted_shows_cross() { + let mut s = fresh_state(); + s.current_idx = 7; + apply_event( + &mut s, + IngestEvent::Aborted { + counts: AggregateCounts { + scanned: 100, + errors: 0, + ..Default::default() + }, + }, + ); + let line = status_line(&s); + assert!(line.starts_with("✗ ingest aborted"), "got: {line}"); + assert!(line.contains("100/100"), "got: {line}"); + } + + #[test] + fn ready_to_clear_false_until_hold_elapses() { + let mut s = fresh_state(); + s.terminal_at = Some(std::time::Instant::now()); + assert!(!ready_to_clear(&s)); + } + + #[test] + fn ready_to_clear_true_in_absence_of_terminal_is_false() { + let s = fresh_state(); + assert!(!ready_to_clear(&s)); + } +} diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index e97c496..2f99c93 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -15,6 +15,7 @@ mod app; mod ask; mod error_popup; +mod ingest_progress; mod inspect; mod library; mod run; @@ -22,10 +23,12 @@ mod search; mod terminal; pub use app::{ - App, AskState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane, SearchState, + App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane, + SearchState, TERMINAL_LINE_HOLD_SECS, }; pub use ask::{handle_key_ask, render_ask}; pub use error_popup::{ErrorOverlay, render_error_overlay}; +pub use ingest_progress::{drain_progress, ready_to_clear, start_ingest, status_line}; pub use inspect::{enter_inspect, handle_key_inspect, render_inspect}; pub use library::{handle_key_library, render_library}; pub use search::{build_jump_command, handle_key_search, jump_to_citation, render_search}; diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs index 1a33c58..0fd22a2 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -273,6 +273,18 @@ pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome { inner.filter_edit = Some(FilterEdit::from_filter(&inner.filter)); KeyOutcome::Continue } + (KeyCode::Char('r'), _) => { + // p9-fb-03: trigger background ingest. The `inner` mutable + // borrow above is not used in this arm, so NLL releases it + // before we re-borrow `state` for `start_ingest`. Errors + // (e.g. "ingest already running") surface via the error + // overlay. + if let Err(e) = crate::ingest_progress::start_ingest(state) { + state.error_overlay = + Some(crate::ErrorOverlay::from_anyhow(&e)); + } + KeyOutcome::Continue + } (KeyCode::Char('/'), _) => KeyOutcome::SwitchPane(Pane::Search), (KeyCode::Char('?'), _) => KeyOutcome::SwitchPane(Pane::Ask), (KeyCode::Enter, _) => { diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index 91e5152..03d816d 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -29,6 +29,31 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { let mut terminal = TuiTerminal::enter()?; while !app.should_quit { + // p9-fb-03: ingest progress is pane-independent. Drain + // freshly-arrived events every tick + clear the slot a few + // seconds after the run terminated so the user has time to + // read the final line. + crate::ingest_progress::drain_progress(app); + let clear_now = app + .ingest_state + .as_ref() + .map(crate::ingest_progress::ready_to_clear) + .unwrap_or(false); + if clear_now { + if let Some(mut state) = app.ingest_state.take() { + // Reap the worker thread now that the user has seen + // the final status line; ignore the join result — + // `IngestReport` was already mirrored into the status + // bar via `Completed { counts }`. + if let Some(handle) = state.thread.take() { + let _ = handle.join(); + } + } + // Library may show stale doc list; queue a refresh so the + // next idle tick picks up the just-ingested rows. + app.library.inner.needs_refresh = true; + } + // Per-pane idle work BEFORE rendering so the frame reflects // freshly-loaded state. if app.error_overlay.is_none() { @@ -149,13 +174,26 @@ fn handle_key_unimplemented_pane( } fn render_root(f: &mut Frame, app: &App) { - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([ + // p9-fb-03: insert a 1-line status bar above the footer when an + // ingest is in flight (or its terminal line is still on hold). + let has_ingest = app.ingest_state.is_some(); + let constraints: Vec = if has_ingest { + vec![ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), // ingest status bar + Constraint::Length(1), // existing footer hints + ] + } else { + vec![ Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), - ]) + ] + }; + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) .split(f.area()); render_header(f, outer[0], app); match app.focus { @@ -166,12 +204,33 @@ fn render_root(f: &mut Frame, app: &App) { // p9-5 Jobs not yet rendered; Library placeholder. Pane::Jobs => render_library(f, outer[1], app), } - render_footer(f, outer[2], app); + if has_ingest { + render_ingest_status(f, outer[2], app); + render_footer(f, outer[3], app); + } else { + render_footer(f, outer[2], app); + } if let Some(err) = &app.error_overlay { render_error_overlay(f, f.area(), err); } } +fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) { + let Some(state) = app.ingest_state.as_ref() else { + return; + }; + let line = crate::ingest_progress::status_line(state); + let style = if state.aborted { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled(line, style))), + area, + ); +} + fn render_header(f: &mut Frame, area: Rect, app: &App) { let pane_label = match app.focus { Pane::Library => "Library", @@ -197,7 +256,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { if app.library.inner.filter_edit.is_some() { "Tab=field Enter=apply Esc=cancel" } else { - "j/k=move gg=top G=bottom f=filter /=search ?=ask Enter=inspect q=quit" + "j/k=move gg=top G=bottom f=filter /=search ?=ask Enter=inspect r=ingest q=quit" } } Pane::Search => "type=query Tab=mode Enter=search j/k=move g=open in $EDITOR Esc=back", diff --git a/tasks/p9/p9-fb-03-tui-ingest-background.md b/tasks/p9/p9-fb-03-tui-ingest-background.md index ee3f60f..78db2a6 100644 --- a/tasks/p9/p9-fb-03-tui-ingest-background.md +++ b/tasks/p9/p9-fb-03-tui-ingest-background.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-tui task_id: p9-fb-03 title: "TUI ingest as background worker + status bar" -status: planned +status: in_progress depends_on: [p9-fb-01] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md