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>
1044 lines
48 KiB
Markdown
1044 lines
48 KiB
Markdown
---
|
||
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._
|