Merge pull request 'feat(kebab-app + kebab-cli): p9-fb-18 CLI ask --session multi-turn' (#82) from feat/p9-fb-18-cli-session into main
This commit was merged in pull request #82.
This commit is contained in:
@@ -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<PathBuf>` 필드 (`#[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<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>` (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 <id>` (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 <id>` 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 후보
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ kebab doctor
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--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 <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요 |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 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 <id> --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 가치 신중).
|
||||
|
||||
|
||||
@@ -254,7 +254,20 @@ impl App {
|
||||
/// Run a RAG `ask` against the configured retriever + LLM. Reuses
|
||||
/// the memoized embedder / vector / LLM where applicable.
|
||||
pub fn ask(&self, query: &str, opts: AskOpts) -> Result<Answer> {
|
||||
let retriever: Arc<dyn Retriever> = match opts.mode {
|
||||
let retriever = self.build_retriever(opts.mode)?;
|
||||
let llm = self.llm()?;
|
||||
let pipeline =
|
||||
RagPipeline::new(self.config.clone(), retriever, llm, self.sqlite.clone());
|
||||
pipeline.ask(query, opts)
|
||||
}
|
||||
|
||||
/// p9-fb-18: shared retriever-stack builder used by [`Self::ask`]
|
||||
/// and [`Self::ask_with_session`]. Lexical mode uses the FTS5
|
||||
/// retriever directly; vector / hybrid require embeddings (and
|
||||
/// surface the same "switch to --mode lexical" error from
|
||||
/// [`Self::require_embeddings`] when disabled).
|
||||
fn build_retriever(&self, mode: SearchMode) -> Result<Arc<dyn Retriever>> {
|
||||
Ok(match mode {
|
||||
SearchMode::Lexical => Arc::new(LexicalRetriever::with_settings(
|
||||
self.sqlite.clone(),
|
||||
lexical_index_version(&self.config),
|
||||
@@ -292,12 +305,129 @@ impl App {
|
||||
)) as Arc<dyn Retriever>;
|
||||
Arc::new(HybridRetriever::new(&self.config, lex, vec_retr))
|
||||
}
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Answer> {
|
||||
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<Turn> 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<kebab_core::Turn> = 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();
|
||||
|
||||
// p9-fb-18 R1: shared retriever builder removes the prior
|
||||
// copy of `ask`'s 35-line stack — see [`Self::build_retriever`].
|
||||
let retriever = self.build_retriever(opts.mode)?;
|
||||
let llm = self.llm()?;
|
||||
let pipeline =
|
||||
RagPipeline::new(self.config.clone(), retriever, llm, self.sqlite.clone());
|
||||
pipeline.ask(query, opts)
|
||||
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
|
||||
@@ -446,3 +576,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <id>` 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<Answer> {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
-- max_context_tokens that produced the session so a retroactive
|
||||
-- answer-quality regression can be re-traced.
|
||||
--
|
||||
-- * `citations_json` carries `Vec<Citation>` so the answer can be
|
||||
-- redisplayed with the same citation markers a future session
|
||||
-- sees on resume.
|
||||
-- * `citations_json` carries `Vec<AnswerCitation>` (per p9-fb-18) —
|
||||
-- each AnswerCitation holds a `Citation` plus `marker`, so the
|
||||
-- answer can be redisplayed with the same citation markers a
|
||||
-- future session sees on resume.
|
||||
--
|
||||
-- * `INTEGER` timestamps (unix epoch seconds) — same convention the
|
||||
-- rest of the schema uses (P1-7 baselines this).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user