Files
kebab/docs/superpowers/plans/2026-05-27-v0.20-sub1-bugfix3-plan.md
altair823 46e99470eb docs(superpowers): v0.20 sub-item 1 bugfix1/2/3 specs + plans + DOGFOOD.md
3-round dogfood-driven fix cycle 의 산출물:

- bugfix1 (Bug #2/#3/#4): spec 964 line + plan 848 line
- bugfix2 (Bug #6/#7, #8 falsified): spec 308 line + plan 388 line
- bugfix3 (Bug #9/#10/#11/#13/#14, #12 falsified): spec 410 line + plan 1043 line
- docs/DOGFOOD.md: 전방위 dogfood checklist 의 전체 (§0 environment ~ §13 reference corpus)

각 round 의 spec/plan 가 critic + verifier round 2 closure ACCEPT 후 frozen. dogfood-driven evidence 기반.

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

1044 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "v0.20.0 sub-item 1 bugfix round 3 — plan"
created: 2026-05-27
status: DRAFT
round: 0
spec_path: docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix3-spec.md
parent_spec: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
brief: .omc/reviews/2026-05-27-v0.20-bugfix3-plan-drafter-brief.md
branch: feat/pdf-scanned-ocr
base_head: f763049
step_count: 7
commit_count: 6
estimated_minutes: 60
---
# v0.20.0 sub-item 1 bugfix round 3 — plan
Spec ACCEPT (`2026-05-27-v0.20-sub1-bugfix3-spec.md`, 410 line, 11/11 critic finding 반영) 의 step-level decomposition. 5 bug (#9 / #10 / #11 / #13 / #14) + HOTFIXES + parent spec cross-link 까지 한 round 에서 처리.
## §0 Overview
### §0.1 Scope
| Bug | Severity | Surface | Type |
|-----|----------|---------|------|
| #9 | critical | `schema.v1.capabilities` | wire field correction (false → true) |
| #10 | medium | `error.v1` | additive error code `config_not_found` |
| #11 | critical UX | `config.pdf.ocr.request_timeout_secs` default | numeric default change (600 → 60) |
| #13 | medium | `schema.v1.models` | additive array fields (backward compat) |
| #14 | minor UX | `kebab search` / `kebab ask` input validation | additive error code path (`invalid_input`) |
| — | — | `tasks/HOTFIXES.md` + parent spec | docs handoff for Bug #11 deviation |
### §0.2 Strategy
- **Per-bug commit boundary** (option A): 한 commit 당 한 bug 만 — revert / bisect 가 정확. Step 6 만 doc-only.
- **wire schema = additive minor**. Bug #13 `models` 의 신규 두 field 는 optional. 기존 client 영향 0. spec §3.4 와 일치.
- **parent spec frozen**: text 변경 0. inline HTML 주석 cross-link 만. HOTFIXES.md 가 live source of truth.
- **subagent skip**: in-session direct execution. spec §7 의 worker protocol 준수.
- **regression budget**: 기존 workspace test 1350 + 본 round 새 +7 ≥ 1357 test, 모두 green.
### §0.3 Environment
```bash
cd /home/altair823/kebab
export CARGO_TARGET_DIR=/build/out/cargo-target/target
git status # working tree clean expected
git rev-parse HEAD # f763049
```
`-j 4` default (workspace memory budget). `-j 1` 은 OOM fallback only — full workspace integration run 일 때만.
---
## §1 Step table
| Step | Subject | Files | New tests | Commit |
|------|---------|-------|-----------|--------|
| 1 | Bug #9 capabilities flip | `crates/kebab-app/src/schema.rs` | 2 unit | `fix(app): flip streaming_ask + single_file_ingest capabilities to actual surface (Bug #9)` |
| 2 | Bug #10 config_not_found error | `crates/kebab-config/src/lib.rs`, `crates/kebab-app/src/error_signal.rs`, `crates/kebab-app/src/error_wire.rs`, `crates/kebab-app/src/lib.rs` (re-export) | 1 unit + 2 integration | `fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)` |
| 3 | Bug #11 OCR timeout 60s | `crates/kebab-config/src/lib.rs` | 1 unit | `fix(config): pdf.ocr.request_timeout_secs default 600 → 60 per dogfood evidence (Bug #11)` |
| 4 | Bug #13 active_parsers + active_chunkers (additive) | `crates/kebab-store-sqlite/src/store.rs` (또는 lib.rs), `crates/kebab-app/src/schema.rs`, `docs/wire-schema/v1/schema.schema.json`, `integrations/claude-code/kebab/SKILL.md` | 2 integration | `feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)` |
| 5 | Bug #14 empty query invalid_input | `crates/kebab-cli/src/main.rs` | 2 integration | `fix(cli): empty query emits error.v1 invalid_input for search + ask (Bug #14)` |
| 6 | HOTFIXES + parent spec cross-link | `tasks/HOTFIXES.md`, `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` | 0 | `docs(spec): HOTFIXES entry + parent spec cross-link for Bug #11 timeout deviation` |
| 7 | Final sanity (no commit) | — | — | n/a |
**Total**: 7 step, 6 commit, 8 new test (3 unit + 5 integration). Reaches spec §5 AC-1 ~ AC-10.
---
## §2 Per-step detail
### Step 1 — Bug #9 capabilities flip
#### §2.1.1 Files affected
- `crates/kebab-app/src/schema.rs:137-151` (`capabilities_snapshot()`).
- `crates/kebab-app/src/schema.rs` (`#[cfg(test)] mod` 또는 새 `mod tests_capabilities` — 기존 `mod tests_stats_ext` 와 동등 위치).
#### §2.1.2 Action
`capabilities_snapshot()` body 의 두 줄만 변경:
```diff
fn capabilities_snapshot() -> Capabilities {
Capabilities {
json_mode: true,
ingest_progress: true,
ingest_cancellation: true,
rag_multi_turn: true,
search_cache: true,
incremental_ingest: true,
- streaming_ask: false,
+ streaming_ask: true,
http_daemon: false,
mcp_server: true,
- single_file_ingest: false,
+ single_file_ingest: true,
bulk_search: true,
}
}
```
`http_daemon: false` 는 보존 — 별도 sub-item 의 non-impl. spec §3.1 의 결정과 일치.
#### §2.1.3 New tests
`crates/kebab-app/src/schema.rs``#[cfg(test)]` 영역에 unit test 2 개 추가:
```rust
#[test]
fn capabilities_streaming_ask_matches_cli_surface() {
// Bug #9: kebab ask --stream 가 answer_event.v1 ndjson 정상 emit (191 event 검증) →
// capabilities.streaming_ask 가 true 여야 함.
let caps = super::capabilities_snapshot();
assert!(caps.streaming_ask, "streaming_ask must be true (Bug #9)");
}
#[test]
fn capabilities_single_file_ingest_matches_cli_surface() {
// Bug #9: kebab ingest-file <path> + kebab ingest-stdin --title <T> 양쪽 모두
// ingest_report.v1 정상 emit → capabilities.single_file_ingest 가 true 여야 함.
let caps = super::capabilities_snapshot();
assert!(caps.single_file_ingest, "single_file_ingest must be true (Bug #9)");
}
```
추가로 `capabilities_snapshot``pub(crate)` 또는 module-internal 일 경우 `super::` path 로 접근. private 라면 같은 module 의 child mod 에서 호출 가능.
#### §2.1.4 Per-step acceptance
```bash
cargo test -p kebab-app capabilities_streaming_ask_matches_cli_surface -j 4
cargo test -p kebab-app capabilities_single_file_ingest_matches_cli_surface -j 4
cargo test -p kebab-app schema -j 4 # 기존 schema_report.rs integration 도 green 유지
cargo clippy -p kebab-app --all-targets -- -D warnings
```
기존 `mod tests_stats_ext``stats_includes_breakdowns_and_bytes_on_fresh_corpus` (schema_with_config 경유) 가 `streaming_ask`/`single_file_ingest` 를 assert 안 함 — regression 없음.
#### §2.1.5 Commit
```bash
git add crates/kebab-app/src/schema.rs
git commit -m "$(cat <<'EOF'
fix(app): flip streaming_ask + single_file_ingest capabilities to actual surface (Bug #9)
capabilities_snapshot() 가 streaming_ask + single_file_ingest 를 hardcoded false 로
보고했으나 실제 구현은 v0.20 final-dogfood 에서 production-grade:
- kebab ask --stream → answer_event.v1 ndjson 191 event 정상 emit
- kebab ingest-file <path> / kebab ingest-stdin --title <T> → ingest_report.v1 정상
MCP host + Claude Code skill 등 agent 가 schema.capabilities 로 routing 결정 시
false negative → 사용자가 실제 동작 feature 를 사용 불가능하다고 오인.
http_daemon 은 false 유지 (별도 sub-item 의 non-impl).
EOF
)"
```
---
### Step 2 — Bug #10 ConfigNotFound + classify arm
#### §2.2.1 Files affected
1. `crates/kebab-config/src/lib.rs`
- 19-22 line 의 `ConfigInvalid` 정의 옆에 `ConfigNotFound` 추가.
- 688-722 line 의 `Config::load``Some(_) => Self::defaults(),` arm 을 `Some(_) => Err(...)` 로 변경.
2. `crates/kebab-app/src/error_signal.rs`
- `pub use kebab_config::ConfigNotFound;` 추가 (기존 `ConfigInvalid` 와 동등 pattern).
3. `crates/kebab-app/src/error_wire.rs`
- `classify``ConfigInvalid` arm 다음에 `ConfigNotFound` arm 추가.
4. `crates/kebab-app/src/lib.rs`
- `pub use kebab_config::ConfigInvalid;` 옆에 `pub use kebab_config::ConfigNotFound;` 추가 (기존 14 line pattern).
#### §2.2.2 Action
**(a) `crates/kebab-config/src/lib.rs` — error type 추가** (line ~25, `ConfigInvalid` 직후):
```rust
/// p20-bugfix3 Bug #10: explicit `--config <path>` 가 missing 시 silent
/// fallback to defaults 대신 fail-fast. `kebab-app::error_wire::classify`
/// 가 downcast → `code: "config_not_found"` ErrorV1.
#[derive(Debug, thiserror::Error)]
#[error("config file does not exist: {path}")]
pub struct ConfigNotFound {
pub path: PathBuf,
}
```
**(b) `crates/kebab-config/src/lib.rs:688-722``Config::load` 분기 수정**:
```diff
pub fn load(path: Option<&Path>) -> anyhow::Result<Self> {
let from_disk = match path {
Some(p) if p.exists() => Self::from_file(p)?,
- Some(_) => Self::defaults(),
+ Some(p) => {
+ // Bug #10: explicit --config 가 missing → silent default fallback 금지.
+ return Err(anyhow::Error::new(ConfigNotFound {
+ path: p.to_path_buf(),
+ }));
+ }
None => {
let p = Self::xdg_config_path();
...
```
상대경로 cover: `Path::exists()` 는 cwd-relative — spec §6 R-1 해결 (별도 작업 0).
**(c) `crates/kebab-app/src/error_signal.rs` — re-export**:
```rust
pub use kebab_config::ConfigNotFound;
```
(기존 `ConfigInvalid` re-export 와 동등 위치. 같은 file 안에서 `use kebab_config::ConfigInvalid;` 이미 있다면 그 옆.)
**(d) `crates/kebab-app/src/error_wire.rs::classify`** — `ConfigInvalid` arm 직후:
```rust
if let Some(s) = err.downcast_ref::<ConfigNotFound>() {
return ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "config_not_found".to_string(),
message: s.to_string(),
details: json!({
"path": s.path.to_string_lossy(),
}),
hint: Some(
"verify --config <path>; pass an existing toml file or omit --config to use XDG default"
.to_string(),
),
};
}
```
상단 `use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed};``ConfigNotFound` 추가.
**(e) `crates/kebab-app/src/lib.rs:14` — public re-export**:
```rust
pub use kebab_config::{ConfigInvalid, ConfigNotFound};
```
#### §2.2.3 New tests
**(a) `crates/kebab-config/src/lib.rs` — unit test (기존 `tests` mod 안)**:
```rust
#[test]
fn config_load_explicit_nonexistent_path_returns_config_not_found() {
// Bug #10: --config /tmp/nonexistent.toml → silent fallback 금지.
let p = std::path::Path::new("/tmp/__kebab_bugfix3_nonexistent.toml");
assert!(!p.exists(), "test precondition: path must not exist");
let err = Config::load(Some(p)).expect_err("expected ConfigNotFound");
let signal = err
.downcast_ref::<ConfigNotFound>()
.expect("from_load error should downcast to ConfigNotFound");
assert_eq!(signal.path, p.to_path_buf());
}
```
**(b) `crates/kebab-cli/tests/cli_error_wire.rs` 또는 신규 `crates/kebab-cli/tests/cli_config_not_found.rs` — integration test 2 개**:
```rust
use std::process::Command;
use serde_json::Value;
fn kebab_bin() -> String {
env!("CARGO_BIN_EXE_kebab").to_string()
}
#[test]
fn invalid_config_path_emits_error_v1_with_nonzero_exit() {
let absent = "/tmp/__kebab_bugfix3_absolute_nonexistent.toml";
assert!(!std::path::Path::new(absent).exists());
let out = Command::new(kebab_bin())
.args(["search", "rust", "--config", absent, "--json"])
.output()
.expect("spawn kebab");
assert_ne!(out.status.code(), Some(0), "exit must be nonzero on missing --config");
let stderr = String::from_utf8_lossy(&out.stderr);
let last_line = stderr.lines().last().expect("error.v1 line on stderr");
let v: Value = serde_json::from_str(last_line)
.unwrap_or_else(|e| panic!("expected error.v1 ndjson on stderr: {e}\nstderr={stderr}"));
assert_eq!(v["schema_version"], "error.v1");
assert_eq!(v["code"], "config_not_found");
assert!(v["hint"].is_string(), "hint must be present");
}
#[test]
fn invalid_relative_config_path_emits_config_not_found() {
// Bug #10 spec §6 R-1: relative path 도 cwd-relative 로 cover.
let tmp = tempfile::tempdir().unwrap();
let out = Command::new(kebab_bin())
.args(["search", "rust", "--config", "nonexistent-rel.toml", "--json"])
.current_dir(tmp.path())
.output()
.expect("spawn kebab");
assert_ne!(out.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&out.stderr);
let last_line = stderr.lines().last().expect("error.v1 line");
let v: Value = serde_json::from_str(last_line).expect("ndjson");
assert_eq!(v["code"], "config_not_found");
}
```
기존 `cli_error_wire.rs` 의 ConfigInvalid integration test 패턴을 참고 (existing test 그대로 green 유지 — fail-fast 가 `ConfigInvalid` (file 존재 + parse 실패) 와 별개 path).
#### §2.2.4 Per-step acceptance
```bash
cargo test -p kebab-config config_load_explicit_nonexistent_path -j 4
cargo test -p kebab-cli invalid_config_path_emits_error_v1_with_nonzero_exit -j 4
cargo test -p kebab-cli invalid_relative_config_path_emits_config_not_found -j 4
cargo test -p kebab-config -j 4 # 기존 18 test 전수 green
cargo test -p kebab-app error_wire -j 4 # 기존 classify test 전수 green (ConfigInvalid 등)
cargo clippy -p kebab-config -p kebab-app -p kebab-cli --all-targets -- -D warnings
```
`Config::load``None → XDG default` path 는 변경 0 — `kebab doctor` (config 없는 fresh clone) regression 없음.
#### §2.2.5 Commit
```bash
git add crates/kebab-config/src/lib.rs \
crates/kebab-app/src/error_signal.rs \
crates/kebab-app/src/error_wire.rs \
crates/kebab-app/src/lib.rs \
crates/kebab-cli/tests/
git commit -m "$(cat <<'EOF'
fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)
이전: `kebab search "rust" --config /tmp/nonexistent.toml --json` 가 exit=0 +
`{"hits":[]}` silent fallback to XDG default. typo / wrong path 가 0-hit 으로만
surface — debugging nightmare.
이후: kebab_config::ConfigNotFound thiserror::Error 추가, Config::load 의
`Some(p) if !p.exists()` arm 이 anyhow::Error::new(ConfigNotFound { path })
return. kebab_app::error_wire::classify 가 downcast → ErrorV1 code=config_not_found,
hint, details.path 채워서 stderr 에 ndjson 으로 emit.
R-1 (relative path): std::path::Path::exists() 는 cwd-relative — 별도 작업 없이
absolute + relative 모두 cover. integration test 두 개로 검증.
EOF
)"
```
---
### Step 3 — Bug #11 OCR timeout 60s
#### §2.3.1 Files affected
- `crates/kebab-config/src/lib.rs:477` (`default_pdf_ocr_request_timeout_secs`).
- `crates/kebab-config/src/lib.rs` `#[cfg(test)] mod tests` (또는 적합한 위치) — 신규 unit test 추가.
#### §2.3.2 Action
```diff
-fn default_pdf_ocr_request_timeout_secs() -> u64 { 600 }
+/// PDF OCR per-page request timeout 의 기본값.
+/// 6-32s 가 정상 throughput; 60s 초과는 Ollama 다운 / 매우 dense·고해상도 page 의 신호.
+/// `config.toml` 의 `[pdf.ocr] request_timeout_secs = N` 로 override.
+///
+/// HOTFIXES 2026-05-27 (Bug #11): metro-korea.pdf dogfood 에서 page 8/13 모두
+/// 기존 600s default 까지 완전 timeout (`chars: 0, skipped: true` × 20분 cost) →
+/// 60s 로 하향. parent spec §1000 / §1628 OQ-1 (CPU 환경 105s 의 5x 여유) 가
+/// 가정한 "page 당 평균 105s" 보다 실측 cloud GPU Ollama 가 6-32s 로 훨씬 빠름.
+fn default_pdf_ocr_request_timeout_secs() -> u64 { 60 }
```
기존 470 line 의 `request_timeout_secs: default_pdf_ocr_request_timeout_secs(),` 는 동일 함수 호출이라 추가 변경 0.
#### §2.3.3 New tests
`crates/kebab-config/src/lib.rs``#[cfg(test)] mod tests`:
```rust
#[test]
fn pdf_ocr_request_timeout_default_is_60s() {
// Bug #11 (dogfood 2026-05-27): default 600s → 60s.
let cfg = PdfOcrCfg::defaults();
assert_eq!(
cfg.request_timeout_secs, 60,
"pdf.ocr.request_timeout_secs default must be 60s (Bug #11, HOTFIXES 2026-05-27)"
);
}
```
기존 unit test 중 600 magic number 를 검증하는 항목이 있다면 동일 commit 안에서 60 으로 갱신. (verify: `grep -rn "request_timeout_secs.*600\|600.*request_timeout_secs" crates/kebab-config/src/` — 발견 시 그 test 만 expect 값 갱신, 새 unit test 와 같은 의미라면 기존 test 만 갱신하고 신규 test 생략 가능. 본 plan 은 spec ACCEPT 의 보수적 선택: 신규 test 도 추가해 unique name 으로 보존.)
#### §2.3.4 Per-step acceptance
```bash
cargo test -p kebab-config pdf_ocr_request_timeout_default_is_60s -j 4
cargo test -p kebab-config -j 4 # 18 test 전수 green; 만일 기존 test 가 600 expect 면 같은 commit 에서 갱신
cargo clippy -p kebab-config --all-targets -- -D warnings
```
`PdfOcrCfg::defaults()` 의 다른 field 는 변경 0 — `max_pixels` (2048), `valid_ratio_threshold` (0.5), `min_char_count` (20), `lang_hint` (`"kor"`) 보존.
#### §2.3.5 Commit
```bash
git add crates/kebab-config/src/lib.rs
git commit -m "$(cat <<'EOF'
fix(config): pdf.ocr.request_timeout_secs default 600 → 60 per dogfood evidence (Bug #11)
metro-korea.pdf v0.20 final-dogfood (2026-05-27):
- page 8 + page 13 양쪽 모두 600s default 까지 완전 timeout
(`ms: 600000, chars: 0, skipped: true`)
- 결과: 본문 indexed 안 됨 + page 당 20분 cost 낭비
cloud GPU Ollama 의 실측 per-page throughput 는 6-32s (parent spec 가정 105s 보다
훨씬 빠름). 60s 면 production-friendly upper-bound. dense/고해상도 page 는
config.toml override (`[pdf.ocr] request_timeout_secs = N`) 로 user 가 늘릴 수
있음 — Step 6 에서 HOTFIXES + parent spec cross-link.
EOF
)"
```
---
### Step 4 — Bug #13 active_parsers + active_chunkers (additive minor)
#### §2.4.1 Files affected
1. `crates/kebab-store-sqlite/src/store.rs` (또는 lib.rs — `impl SqliteStore` 의 다른 fetch_* method 와 같은 file).
2. `crates/kebab-app/src/schema.rs` (`Models` struct 정의 위치 — `kebab-app``pub struct Models` 검색해 동일 file 안에 추가).
3. `docs/wire-schema/v1/schema.schema.json``models.properties` 에 두 array 추가.
4. `integrations/claude-code/kebab/SKILL.md``models` description 갱신.
5. `crates/kebab-app/tests/schema_report.rs` (또는 신규 file) — integration test 2개.
#### §2.4.2 Action
**(a) `crates/kebab-store-sqlite/src/store.rs``impl SqliteStore`** — 신규 method 2개:
```rust
/// p20-bugfix3 Bug #13: schema.v1.models.active_parsers 의 source.
/// `documents.parser_version` 컬럼의 DISTINCT 값을 정렬해 반환.
/// 빈 corpus → 빈 Vec.
pub fn fetch_distinct_parser_versions(&self) -> anyhow::Result<Vec<String>> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT DISTINCT parser_version FROM documents
WHERE parser_version IS NOT NULL AND parser_version != ''
ORDER BY parser_version",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// p20-bugfix3 Bug #13: schema.v1.models.active_chunkers 의 source.
/// `chunks.chunker_version` 컬럼의 DISTINCT 값을 정렬해 반환.
pub fn fetch_distinct_chunker_versions(&self) -> anyhow::Result<Vec<String>> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT DISTINCT chunker_version FROM chunks
WHERE chunker_version IS NOT NULL AND chunker_version != ''
ORDER BY chunker_version",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
```
note: `self.conn()``SqliteStore` 의 기존 connection accessor 가 아니면 같은 file 안 기존 method 의 connection 획득 pattern 을 그대로 사용 (`code_lang_breakdown`, `repo_breakdown`, `corpus_revision` 가 참조 모델).
**(b) `crates/kebab-app/src/schema.rs``Models` struct 확장**:
```diff
pub struct Models {
pub parser_version: String,
pub chunker_version: String,
+ /// v0.20.1+ (Bug #13). Corpus 안 활성 parser version 전체.
+ /// 빈 corpus → empty Vec. backward compat: `parser_version` field 보존.
+ #[serde(default)]
+ pub active_parsers: Vec<String>,
+ /// v0.20.1+ (Bug #13). Corpus 안 활성 chunker version 전체.
+ /// 빈 corpus → empty Vec.
+ #[serde(default)]
+ pub active_chunkers: Vec<String>,
pub embedding_version: String,
pub prompt_template_version: String,
pub index_version: String,
pub corpus_revision: u64,
}
```
`#[serde(default)]` 는 v0.20.0 이전 client 가 schema.v1 deserialize 시 backward compat (없는 field → `Vec::new()`).
**(c) `crates/kebab-app/src/schema.rs:192-207``collect_models` 갱신**:
```diff
fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> Models {
+ let active_parsers = store.fetch_distinct_parser_versions().unwrap_or_default();
+ let active_chunkers = store.fetch_distinct_chunker_versions().unwrap_or_default();
+
Models {
parser_version: kebab_parse_md::PARSER_VERSION.to_string(),
chunker_version: cfg.chunking.chunker_version.clone(),
+ active_parsers,
+ active_chunkers,
embedding_version: cfg.models.embedding.model.clone(),
prompt_template_version: cfg.rag.prompt_template_version.clone(),
index_version: kebab_store_vector::INDEX_VERSION_STR.to_string(),
corpus_revision: store.corpus_revision(),
}
}
```
R-3 (spec §6) 해결: `collect_models` 가 매 schema 호출마다 재계산 — cache 없음, stale 위험 없음.
**markdown PARSER_VERSION 보존**: 기존 `parser_version` field 는 `kebab_parse_md::PARSER_VERSION` (markdown default) 그대로 — backward compat. spec §3.4 의 결정과 일치.
**(d) `docs/wire-schema/v1/schema.schema.json``models.properties` 갱신**:
```diff
"models": {
"type": "object",
"required": [
"parser_version", "chunker_version", "embedding_version",
"prompt_template_version", "index_version", "corpus_revision"
],
"properties": {
"parser_version": { "type": "string" },
"chunker_version": { "type": "string" },
+ "active_parsers": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "v0.20.1+ (Bug #13). 활성 parser version 전체 (DISTINCT, ORDER BY). 빈 corpus → []. backward-compat: optional, 기존 client 무영향."
+ },
+ "active_chunkers": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "v0.20.1+ (Bug #13). 활성 chunker version 전체 (DISTINCT, ORDER BY). 빈 corpus → []."
+ },
"embedding_version": { "type": "string" },
"prompt_template_version": { "type": "string" },
"index_version": { "type": "string" },
"corpus_revision": { "type": "integer", "minimum": 0 }
}
},
```
`required` array 에는 추가하지 않음 — additive minor 의 정의.
**(e) `integrations/claude-code/kebab/SKILL.md:155` — description 갱신**:
```diff
-Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (...
+Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis + v0.20.1 `active_parsers` / `active_chunkers` arrays for multi-version corpora), `stats` (...
```
`description` frontmatter 는 generic 유지 — per-user trigger keyword 는 user 의 local copy 만.
#### §2.4.3 New tests
`crates/kebab-app/tests/schema_report.rs` (또는 신규 file `crates/kebab-app/tests/schema_active_versions.rs`):
```rust
use kebab_app::schema_with_config;
use kebab_config::Config;
#[test]
fn schema_models_active_arrays_empty_on_empty_corpus() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
drop(store);
let s = schema_with_config(&cfg).unwrap();
assert!(s.models.active_parsers.is_empty(), "empty corpus → no parsers");
assert!(s.models.active_chunkers.is_empty(), "empty corpus → no chunkers");
// backward compat: 기존 단일 field 는 markdown default 보존.
assert_eq!(s.models.parser_version, kebab_parse_md::PARSER_VERSION);
}
#[test]
fn schema_emits_active_parsers_and_chunkers_array_after_mixed_ingest() {
// markdown + (선택적) code ingest 후 active_parsers/chunkers 가 비어있지 않음.
// 본 test 는 kebab-app 의 ingest_with_config + schema_with_config 조합 — 기존
// ingest_lexical.rs / code_ingest_smoke.rs 의 helper fixture 재활용 가능.
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
cfg.workspace.root = {
let kb = dir.path().join("kb");
std::fs::create_dir_all(&kb).unwrap();
std::fs::write(kb.join("a.md"), "# A\nhello\n").unwrap();
kb.to_string_lossy().into_owned()
};
// Minimal ingest — markdown only 면 active_parsers = ["md-frontmatter-v2"]
// (또는 PARSER_VERSION 의 string label) 1 entry.
kebab_app::ingest_with_config(&cfg, false).unwrap();
let s = schema_with_config(&cfg).unwrap();
assert!(!s.models.active_parsers.is_empty(), "active_parsers populated after ingest");
assert!(!s.models.active_chunkers.is_empty(), "active_chunkers populated after ingest");
// ORDER BY → sorted (lex order).
let mut sorted = s.models.active_parsers.clone();
sorted.sort();
assert_eq!(s.models.active_parsers, sorted, "active_parsers must be sorted");
}
```
note: `kebab_app::ingest_with_config` 정확한 시그니처 (`fn(cfg: &Config, summary_only: bool)` 또는 `fn(scope: SourceScope, summary_only: bool)`) 는 기존 `ingest_lexical.rs` 의 helper 와 동일 pattern 으로 — executor 가 in-tree resolution.
#### §2.4.4 Per-step acceptance
```bash
cargo test -p kebab-store-sqlite fetch_distinct -j 4 # 신규 store method (있으면)
cargo test -p kebab-app schema_models_active_arrays_empty_on_empty_corpus -j 4
cargo test -p kebab-app schema_emits_active_parsers_and_chunkers_array_after_mixed_ingest -j 4
cargo test -p kebab-app schema -j 4 # 기존 schema_report.rs 전수 green (특히 stats_includes_*)
cargo clippy -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings
# JSON schema lint (additive minor check)
python3 -c "import json; json.load(open('docs/wire-schema/v1/schema.schema.json'))"
```
`stats_includes_breakdowns_and_bytes_on_fresh_corpus``s.models` 를 assert 안 함 — regression 없음. backward compat: 기존 `parser_version` / `chunker_version` 값 보존.
#### §2.4.5 Commit
```bash
git add crates/kebab-store-sqlite/src/ \
crates/kebab-app/src/schema.rs \
crates/kebab-app/tests/ \
docs/wire-schema/v1/schema.schema.json \
integrations/claude-code/kebab/SKILL.md
git commit -m "$(cat <<'EOF'
feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)
이전: schema.v1.models 가 parser_version / chunker_version 단일 값만 보고 →
multi-medium corpus (md + pdf + code Rust/Python + dockerfile + k8s + manifest)
의 version cascade audit 누락 risk.
이후: additive minor — Models struct 에 active_parsers + active_chunkers Vec<String>
추가. backward compat: 기존 단일 field 보존 (markdown default), 신규 array 는
optional (#[serde(default)] + JSON schema required 미포함).
source:
- kebab_store_sqlite::fetch_distinct_parser_versions() 가
documents.parser_version DISTINCT + ORDER BY 반환.
- fetch_distinct_chunker_versions() 가 chunks.chunker_version 동일 pattern.
- collect_models 가 매 schema 호출마다 재계산 (cache 없음 — R-3 자동 해결).
wire schema additive only — 메이저 bump 불필요. v0.20.1 minor 로 충분.
integrations/claude-code/kebab/SKILL.md 동기 갱신.
EOF
)"
```
---
### Step 5 — Bug #14 empty query (search + ask)
#### §2.5.1 Files affected
- `crates/kebab-cli/src/main.rs:818-826` (search arm 의 query_text 해석부).
- `crates/kebab-cli/src/main.rs:990` 부근 (ask arm 의 Config::load 직후 — query 변수가 이미 `&String` available).
- `crates/kebab-cli/tests/` — 신규 integration test 2개 (또는 `cli_error_wire.rs` 안 추가).
#### §2.5.2 Action
**(a) search arm** (line 821-826):
```diff
// p9-fb-42: bulk mode requires no query; single-query mode requires query.
let query_text = match query.as_ref() {
- Some(q) => q.clone(),
+ Some(q) if q.trim().is_empty() => {
+ return Err(anyhow::Error::new(kebab_app::StructuredError(
+ kebab_app::ErrorV1 {
+ schema_version: kebab_app::ERROR_V1_ID.to_string(),
+ code: "invalid_input".to_string(),
+ message: "query is empty; provide a non-empty search term or use --bulk".into(),
+ details: serde_json::Value::Null,
+ hint: Some("e.g. `kebab search 'rust async'` or `kebab search --bulk < queries.ndjson`".into()),
+ },
+ )));
+ }
+ Some(q) => q.clone(),
None => {
return Err(anyhow::anyhow!("query is required unless --bulk is set"));
}
};
```
`--bulk` mode 우선 — 기존 line 730 의 `if *bulk { ... return Ok(()); }` 가 먼저라 empty query check 가 영향 0.
**(b) ask arm** (line 990 의 `let cfg = ...` 직후):
```diff
} => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
+ if query.trim().is_empty() {
+ return Err(anyhow::Error::new(kebab_app::StructuredError(
+ kebab_app::ErrorV1 {
+ schema_version: kebab_app::ERROR_V1_ID.to_string(),
+ code: "invalid_input".to_string(),
+ message: "query is empty; provide a non-empty prompt".into(),
+ details: serde_json::Value::Null,
+ hint: Some("e.g. `kebab ask \"explain this code\"`".into()),
+ },
+ )));
+ }
if *stream {
```
`query: String` (Some 강제 — line 206) 라 `.trim()` 직접 호출 가능.
#### §2.5.3 New tests
`crates/kebab-cli/tests/cli_empty_query.rs` (신규) 또는 `cli_error_wire.rs` 안:
```rust
use std::process::Command;
use serde_json::Value;
fn kebab_bin() -> String {
env!("CARGO_BIN_EXE_kebab").to_string()
}
fn parse_error_v1(stderr: &str) -> Value {
let last = stderr.lines().last().expect("stderr ndjson");
serde_json::from_str(last).unwrap_or_else(|e| panic!("expected ndjson: {e}\n{stderr}"))
}
#[test]
fn search_empty_query_emits_invalid_input() {
for q in ["", " "] {
let out = Command::new(kebab_bin())
.args(["search", q, "--json"])
.output()
.expect("spawn");
assert_ne!(out.status.code(), Some(0), "empty/whitespace query must fail: {q:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
let v = parse_error_v1(&stderr);
assert_eq!(v["schema_version"], "error.v1");
assert_eq!(v["code"], "invalid_input", "stderr={stderr}");
}
}
#[test]
fn ask_empty_query_emits_invalid_input() {
let out = Command::new(kebab_bin())
.args(["ask", "", "--json"])
.output()
.expect("spawn");
assert_ne!(out.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&out.stderr);
let v = parse_error_v1(&stderr);
assert_eq!(v["code"], "invalid_input");
}
```
#### §2.5.4 Per-step acceptance
```bash
cargo test -p kebab-cli search_empty_query_emits_invalid_input -j 4
cargo test -p kebab-cli ask_empty_query_emits_invalid_input -j 4
cargo test -p kebab-cli -j 4 # 기존 cli_error_wire / cli_help_smoke / ... 전수 green
cargo clippy -p kebab-cli --all-targets -- -D warnings
```
`--bulk < ndjson` 의 empty stdin path 는 spec §2.2 의 별도 case — 본 fix 범위 외 (`bulk` arm 이 query 무시).
#### §2.5.5 Commit
```bash
git add crates/kebab-cli/src/main.rs crates/kebab-cli/tests/
git commit -m "$(cat <<'EOF'
fix(cli): empty query emits error.v1 invalid_input for search + ask (Bug #14)
이전: `kebab search "" --json` / `kebab search " " --json` / `kebab ask "" --json`
모두 exit=0 + silent 0 hit (search) 또는 LLM 빈 prompt round-trip (ask). user
mistake (typo, shell expansion 실수) 가 silent → debugging 비용.
이후: 양쪽 arm 에서 `query.trim().is_empty()` → kebab_app::StructuredError
(ErrorV1, code=invalid_input, hint 포함). exit=2 (StructuredError → 기존
exit_code() 의 generic non-zero path).
--bulk mode 는 영향 0 (bulk arm 이 query 무시).
EOF
)"
```
---
### Step 6 — HOTFIXES + parent spec cross-link (Bug #11 deviation)
#### §2.6.1 Files affected
- `tasks/HOTFIXES.md` — dated entry append.
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` — inline HTML 주석 1 줄 (PDF OCR config 또는 OQ-1 의 timeout 600 언급 위치 가까이).
#### §2.6.2 Action
**(a) `tasks/HOTFIXES.md` — append dated subsection**:
기존 HOTFIXES.md "How to add an entry" 섹션 직전에 5-field 형식으로 추가:
```markdown
## 2026-05-27 — PDF OCR `request_timeout_secs` default 600s → 60s (Bug #11)
**Discovered**: v0.20.0 final dogfood (2026-05-27), metro-korea.pdf 의 page 8 + 13.
**Symptom**: 두 page 모두 `kebab ingest` 가 600s 까지 완전 timeout (`ms: 600000, chars: 0, skipped: true`). 본문 indexed 안 됨, page 당 20분 cost 낭비, user 가 ingest 완료 signal 못 받음.
**Root cause**: `default_pdf_ocr_request_timeout_secs() = 600` (parent spec `2026-04-27-kebab-final-form-design.md` §1000 + §1628 OQ-1 의 "CPU 환경 105s 의 5x 여유" 가정). 실측 cloud GPU Ollama 의 per-page throughput 는 6-32s — 600s 까지 가야 timeout 이라면 Ollama 다운 상태가 사실상 확실. 600s 가 fail-fast 신호로 작동 안 함.
**Fix** (v0.20.0 bugfix3 round 3, branch `feat/pdf-scanned-ocr`):
- `crates/kebab-config/src/lib.rs:477` `default_pdf_ocr_request_timeout_secs() = 60`.
- Doc-comment 보강 — 6-32s 정상 throughput, 60s 초과는 Ollama 다운 / 매우 dense·고해상도 page 신호.
- User override path 보존 — `config.toml [pdf.ocr] request_timeout_secs = N` 로 늘릴 수 있음 (release notes 에 명문).
**Amends**: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §1000 / §1628 OQ-1 (parent spec frozen — text 변경 없음, inline HTML 주석 cross-link 1 줄만 추가). 본 entry 가 live source of truth.
```
날짜 헤더의 위치는 기존 entries 의 시간순 (가장 최근이 file 위쪽 또는 아래쪽 — 본 file 의 `## 2026-05-01 —` 이후로 이어지는 자리). executor 가 file head + 가장 최근 dated subsection 의 위치 보고 정확한 anchor 삽입.
**(b) parent spec inline 주석** — frozen text 보존:
`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 의 PDF OCR config block (또는 OQ-1 timeout 언급 위치, brief 의 §1000/§1628 reference) 에 다음 1 줄 HTML 주석 inline 추가:
```markdown
<!-- HOTFIX 2026-05-27: pdf.ocr.request_timeout_secs default 60s (Bug #11). See tasks/HOTFIXES.md 2026-05-27 entry. -->
```
위치: executor 가 `grep -n "request_timeout_secs\|600\|pdf.*timeout\|OQ-1" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 로 가장 가까운 anchor 식별 후 그 line 의 직후 또는 같은 paragraph 끝에 inline 으로 삽입. **frozen text 의 prose 자체는 변경 0** — HTML 주석 (`<!-- ... -->`) 은 markdown render 시 invisible.
#### §2.6.3 New tests
없음 (docs-only commit).
#### §2.6.4 Per-step acceptance
```bash
# parent spec 의 prose diff 가 주석 1 줄 외에는 0 인지 확인:
git diff docs/superpowers/specs/2026-04-27-kebab-final-form-design.md | grep -E "^[+-][^<]" | head -20
# 위 결과는 모두 "+<!-- HOTFIX 2026-05-27 ... -->" 만 보여야 함.
# HOTFIXES entry markdown render 검증 (link sanity):
python3 -c "import pathlib; t = pathlib.Path('tasks/HOTFIXES.md').read_text(); assert '2026-05-27 — PDF OCR' in t and 'Bug #11' in t"
# (optional) markdownlint 가 repo 에 wired 되어 있으면 양쪽 file 에 대해 실행.
```
#### §2.6.5 Commit
```bash
git add tasks/HOTFIXES.md docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
git commit -m "$(cat <<'EOF'
docs(spec): HOTFIXES entry + parent spec cross-link for Bug #11 timeout deviation
Bug #11 (이전 commit `fix(config): pdf.ocr.request_timeout_secs default 600 → 60`)
의 frozen-spec deviation handoff.
- tasks/HOTFIXES.md: 2026-05-27 dated subsection — Discovered / Symptom / Root cause /
Fix / Amends 5-field 포맷 (기존 entries 와 일치).
- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md: PDF OCR config block
(§1000 / §1628 OQ-1 부근) 에 inline HTML 주석 1 줄 cross-link. prose 변경 0 —
parent spec frozen contract 보존, HTML 주석은 markdown render 시 invisible.
HOTFIXES entry 가 live source of truth (CLAUDE.md "Spec contract" 규칙).
EOF
)"
```
---
### Step 7 — Final sanity (no commit)
#### §2.7.1 Workspace-wide check
```bash
# 전체 빌드 + clippy 한 번에:
cargo build --workspace --release -j 4
cargo clippy --workspace --all-targets -- -D warnings
# 전체 test (-j 1 — 18 integration-test binary 의 link OOM 방지):
cargo test --workspace --no-fail-fast -j 1
```
기준: 기존 1350 test + 본 round 새 +7 test = **1357+ test, 모두 green**. fail 시 step-별로 어디서 regression 인지 isolate.
#### §2.7.2 (Optional) Dogfood retest
지금 fresh release binary 가 이미 bugfix2 round 까지 반영 (`/build/out/cargo-target/target/release/kebab`). bugfix3 commit 후 release rebuild + 5 bug 별 수동 smoke:
```bash
# Bug #9: capabilities 둘 다 true.
kebab schema --json | jq '.capabilities | {streaming_ask, single_file_ingest}'
# Bug #10: nonexistent --config → exit≠0 + error.v1 code=config_not_found.
kebab search rust --config /tmp/nope.toml --json; echo "exit=$?"
# Bug #11: defaults 의 timeout 60.
kebab config dump 2>/dev/null | grep request_timeout_secs # (또는 init template 확인)
# Bug #13: mixed corpus 에서 active_parsers/chunkers 둘 다 populate.
kebab schema --json | jq '.models | {active_parsers, active_chunkers}'
# Bug #14: empty query 양쪽 모두 invalid_input.
kebab search "" --json; echo "exit=$?"
kebab ask "" --json; echo "exit=$?"
```
dogfood 는 optional — workspace test green + clippy clean 가 commit 의 충분 조건. dogfood 결과는 final round 의 review 단계에서 캡쳐.
#### §2.7.3 Branch state
```bash
git log --oneline -7
# (예상)
# <h6> docs(spec): HOTFIXES entry + parent spec cross-link for Bug #11 timeout deviation
# <h5> fix(cli): empty query emits error.v1 invalid_input for search + ask (Bug #14)
# <h4> feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)
# <h3> fix(config): pdf.ocr.request_timeout_secs default 600 → 60 per dogfood evidence (Bug #11)
# <h2> fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)
# <h1> fix(app): flip streaming_ask + single_file_ingest capabilities to actual surface (Bug #9)
# f763049 test(cli): assert 'code' in search --help output (Bug #7 regression pin)
```
6 commit (Step 6 = 1 doc commit). PR 는 `feat/pdf-scanned-ocr` branch 그대로 force-update (base=main, spec §0 의 "PR #189 force-update" 참조).
---
## §3 Verifier checklist
cumulative — Step 7 까지 진행 후 verifier 가 점검.
| # | Criterion | Command | Expected | Spec AC |
|----|----------|---------|----------|---------|
| V-1 | capabilities flag 둘 다 true (`streaming_ask` + `single_file_ingest`) | `cargo test -p kebab-app capabilities_streaming_ask_matches_cli_surface capabilities_single_file_ingest_matches_cli_surface -j 4` | green | AC-1 |
| V-2 | absolute missing `--config` → exit≠0 + error.v1 code=config_not_found | `cargo test -p kebab-cli invalid_config_path_emits_error_v1_with_nonzero_exit -j 4` | green | AC-2 |
| V-3 | relative missing `--config` → exit≠0 + error.v1 code=config_not_found | `cargo test -p kebab-cli invalid_relative_config_path_emits_config_not_found -j 4` | green | AC-7 |
| V-4 | OCR timeout default 60s | `cargo test -p kebab-config pdf_ocr_request_timeout_default_is_60s -j 4` | green | AC-3 |
| V-5 | active_parsers/chunkers populate on mixed corpus + 빈 corpus 빈 array | `cargo test -p kebab-app schema_emits_active_parsers_and_chunkers_array_after_mixed_ingest schema_models_active_arrays_empty_on_empty_corpus -j 4` | green | AC-4 |
| V-6 | empty query (search "" + " ") → invalid_input | `cargo test -p kebab-cli search_empty_query_emits_invalid_input -j 4` | green | AC-5 |
| V-7 | empty query (ask "") → invalid_input | `cargo test -p kebab-cli ask_empty_query_emits_invalid_input -j 4` | green | AC-6 |
| V-8 | workspace test 전수 green | `cargo test --workspace --no-fail-fast -j 1` | exit=0, 1357+ test green | AC-8 |
| V-9 | clippy clean | `cargo clippy --workspace --all-targets -- -D warnings` | exit=0, warn 0 | AC-7 spec layer |
| V-10 | wire schema additive minor valid (parse + required 유지) | `python3 -c "import json; s=json.load(open('docs/wire-schema/v1/schema.schema.json')); assert 'active_parsers' in s['properties']['models']['properties']; assert 'active_parsers' not in s['properties']['models']['required']"` | exit=0 | AC-9 |
| V-11 | SKILL.md 동기 갱신 (active_* 언급) | `grep -q "active_parsers\|active_chunkers" integrations/claude-code/kebab/SKILL.md` | exit=0 | AC-10 |
| V-12 | HOTFIXES entry + parent spec cross-link 존재 | `grep -q "2026-05-27 — PDF OCR" tasks/HOTFIXES.md && grep -q "HOTFIX 2026-05-27: pdf.ocr" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` | exit=0 | AC-8 spec layer |
| V-13 | parent spec prose 변경 0 (HTML 주석만) | `git diff main -- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md \| grep -E "^[+-][^<]" \| wc -l` | 0 | constraint §7.2 |
| V-14 | (optional manual) `kebab ask --stream` regression 없음 | `kebab ask --stream "what is rust" 2>&1 \| head -3` | answer_event.v1 ndjson | AC-10 manual |
V-1 ~ V-12 자동, V-13 자동 (diff line count 0), V-14 manual / optional.
---
## §4 Risks resolution (spec §6 의 plan-level)
| Risk | Resolution | Step | Verification |
|------|-----------|------|--------------|
| R-1 — config layer 순서 (kebab-config error vs kebab-app classify) | spec §3.2 의 (a) 선택: `kebab-config` 자체 error type + `kebab-app` downcast. 기존 `ConfigInvalid` pattern 그대로 mirror (re-export 4 file: error_signal.rs, lib.rs, error_wire.rs, classify). | Step 2 | V-2 + V-3 + 기존 error_wire test (config_invalid_classifies_to_config_invalid_code) 보존. |
| R-2 — config relative path (cwd-relative) | `std::path::Path::exists()` 는 cwd-relative — 별도 작업 0. integration test 가 `tempfile::tempdir() + current_dir(...)` 로 absolute / relative 양쪽 cover. | Step 2 | V-3. |
| R-3 — active_* cache invalidation | `collect_models` 가 매 schema 호출마다 store query 직접 — cache 없음. R-3 N/A. | Step 4 | V-5 (빈 corpus + mixed 둘 다). 향후 caching 추가 시 corpus_revision invalidation 명문. |
| R-4 — corpus shrink 시 stale | 위와 동일 (every-call 재계산). | Step 4 | V-5. |
| R-5 — 60s 도 dense/고해상도 page timeout 가능 | mitigation: config.toml `[pdf.ocr] request_timeout_secs = N` override path 유지. 새 release notes 명문 (v0.20.1 minor bump 시). | Step 3 + Step 6 | V-4 (default value test). release notes 는 본 plan 범위 외 — gitea-release 시 cover. |
추가 OQ — Step 2 의 ConfigNotFound 가 `Config::from_file` 의 read_failed (file 존재하나 IO error) 와 구분되는가? **결정**: 본 fix 는 `!p.exists()` path 만 처리. file 은 존재하나 permission denied 등 IO error 는 기존 `ConfigInvalid::read_failed` (line 729-733) path 그대로 — 두 error 가 명확히 disjoint.
---
## §5 Open questions for executor
1. **Step 2 (b)**`ConfigNotFound` 추가 시 `crates/kebab-app/src/error_signal.rs` 의 정확한 export shape: 기존 `pub use kebab_config::ConfigInvalid;` 가 그 file 에 있는지 vs `lib.rs` 직접 인지 grep 으로 확인. 기존 pattern 그대로 mirror.
2. **Step 3** — 기존 unit test 중 `request_timeout_secs.*600` 가 있는지 `grep -rn "request_timeout_secs.*600\|600.*request_timeout_secs" crates/kebab-config/` 로 사전 확인. 발견 시 같은 commit 안에서 expect 값을 60 으로 갱신 (별도 commit 금지 — atomic).
3. **Step 4 (a)**`SqliteStore::conn()` 의 정확한 method 이름이 다른 file 에 따라 (`get_conn`, `connection`, `with_conn` 등) 다를 수 있음. 기존 `code_lang_breakdown` / `repo_breakdown` impl 옆에 같은 pattern 으로 추가 — connection 획득 line 그대로 복붙.
4. **Step 4 (e)**`integrations/claude-code/kebab/SKILL.md` 의 schema description 갱신 범위: brief §0.1 의 "6-axis → 8-axis 또는 '+ active arrays'" 중 후자 채택 (6-axis 라는 표현이 다른 곳에 인용될 수 있어 number 를 늘리는 대신 "+ array" 추가 형식 — backward compat 표현).
5. **Step 6 (b)** — parent spec inline 주석의 정확한 anchor: brief 의 "§1000 / §1628 OQ-1" 은 spec body 안 section number 가 아닌 line number 일 가능성. executor 가 `wc -l docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` + `grep -n "request_timeout_secs\|OQ-1\|600" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 결과로 가장 가까운 anchor 식별 후 inline insertion. **prose 변경 0** 이 핵심 invariant.
6. **Step 5 — `--bulk` precedence**: 기존 line 730 (`Some(bulk)` 분기) 이 query empty check 보다 먼저인지 확인. 본 plan 은 그렇다고 가정 (line 818 에서 cfg load 가 일어나기 전에 bulk return) — false 면 bulk path 가 empty check 의 영향을 받음.
---
## §6 References
- spec: `docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix3-spec.md` (410 line, ACCEPT 11/11)
- brief: `.omc/reviews/2026-05-27-v0.20-bugfix3-plan-drafter-brief.md`
- prior critic rounds:
- `.omc/reviews/2026-05-27-v0.20-bugfix3-spec-closure-result.md`
- `.omc/reviews/2026-05-27-v0.20-bugfix3-spec-closure-r2-result.md`
- source dogfood report: `.omc/reviews/2026-05-27-v0.20-final-dogfood-report.md`
- code anchors:
- `crates/kebab-app/src/schema.rs:137-151` (capabilities_snapshot)
- `crates/kebab-app/src/schema.rs:192-207` (collect_models)
- `crates/kebab-config/src/lib.rs:19-22` (ConfigInvalid pattern model)
- `crates/kebab-config/src/lib.rs:477` (default_pdf_ocr_request_timeout_secs)
- `crates/kebab-config/src/lib.rs:688-722` (Config::load)
- `crates/kebab-app/src/error_wire.rs:49-104` (classify)
- `crates/kebab-cli/src/main.rs:206` (Ask query: String)
- `crates/kebab-cli/src/main.rs:718` (Cmd::Search)
- `crates/kebab-cli/src/main.rs:818-826` (search arm Config::load + query_text)
- `crates/kebab-cli/src/main.rs:977-990` (Cmd::Ask)
- `docs/wire-schema/v1/schema.schema.json:30-44` (Models object)
- `integrations/claude-code/kebab/SKILL.md:155` (schema description)
- `tasks/HOTFIXES.md` (dated entries pattern)
---
## §7 Constraints (spec §7 mirror)
1. **branch 변경 0** — 모든 commit 이 `feat/pdf-scanned-ocr` 에 올라감. base=main 의 force-update 만 (Step 7 후 PR push).
2. **spec ACCEPT (frozen contract) 변경 0**`docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix3-spec.md` 본 plan 안에서 read-only.
3. **regression 0** — 기존 workspace test (~1350) 전수 green + 본 round 새 +7 test 추가 (총 1357+).
4. **wire schema = additive minor** — Bug #13`active_parsers` / `active_chunkers` 는 optional, JSON schema `required` 미포함, `#[serde(default)]`. v0.20.1 minor bump 로 충분.
5. **parent spec text 변경 = inline HTML 주석 1 줄만** — frozen prose 보존, HOTFIXES.md 가 live source of truth.
6. **subagent skip** — direct in-session 작성, nested worker spawn 금지 (worker protocol).
7. **commit message style**: 기존 commit log (`f763049 test(cli): ...`, `8cf73d1 docs(cli): ...`, `a58ee10 fix(parse-pdf): ...`) 의 `kind(scope): subject (Bug #N)` pattern 그대로. body 는 Why + What — 본 plan 의 commit block 그대로 사용.
8. **estimated time**: 60 min — Step 1 (5min) + Step 2 (15min) + Step 3 (5min) + Step 4 (20min) + Step 5 (10min) + Step 6 (5min) + Step 7 sanity. spec 의 30-45 min 보다 보수적.
---
_End of plan._