Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d4a648349 | |||
|
|
b20c1dd56a | ||
| 834a1e1723 | |||
|
|
3328760dca | ||
| f25e16f80c | |||
| 4475abbf4f | |||
|
|
5be90cffec | ||
| 6f0b2bcc37 | |||
|
|
36fe7416c8 | ||
| d6e2e6273e | |||
|
|
cb266e0071 | ||
|
|
ee15528acf | ||
| e03b754a16 | |||
|
|
6b13d8e11f | ||
| fea91d5c99 | |||
|
|
0e762e6374 | ||
|
|
b230fbb495 | ||
|
|
afbd64dafc | ||
|
|
6bedba4a7f | ||
|
|
fd4125c0a0 | ||
|
|
4191347491 | ||
|
|
dd33902f5a | ||
|
|
c8a8bc9045 | ||
|
|
2de28c43da | ||
|
|
9d96504bd9 | ||
| 6dcc5ce412 | |||
|
|
751377cae8 | ||
| 4922f9fc64 | |||
|
|
47bfd518c8 | ||
|
|
7f5739d8fb | ||
|
|
dc24cb34b1 | ||
|
|
ccee30037d | ||
|
|
e041173e8e | ||
|
|
345a4f363a | ||
|
|
71c2bbdc97 | ||
|
|
ecd77290cd | ||
|
|
fbc01eda50 | ||
|
|
0386adcb5e | ||
|
|
9cc7deca11 | ||
|
|
a42f907640 | ||
|
|
67050016cc | ||
|
|
73ee64c73f | ||
|
|
9b53dcb94f | ||
|
|
41061a38ac | ||
| 177ce21f88 | |||
|
|
b7c85e8887 | ||
|
|
7772fbc00f |
@@ -60,7 +60,7 @@ Read the relevant task spec's deps section before adding an import. New crates i
|
||||
|
||||
## Wire schema v1
|
||||
|
||||
All `--json` output carries a `schema_version` field. Current schemas: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `eval_run.v1`, `eval_compare.v1`, `list_docs.v1`, `schema.v1`, `error.v1`. Schemas live in `docs/wire-schema/v1/`. The wire shape is the contract for external integrations (Claude Code skills, MCP, etc.); breaking it requires a `*.v2` major bump and parallel-running both for one phase. In `--json` mode, fatal errors emit `error.v1` to stderr as ndjson (non-`--json` mode keeps plain stderr text); exit codes 0/1/2/3 are unchanged — `error.v1.code` provides fine-grained agent branching.
|
||||
All `--json` output carries a `schema_version` field. Current schemas: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`, `error.v1`, `chunk_inspection.v1`, `citation.v1`, `doc_summary.v1`. Schemas live in `docs/wire-schema/v1/`. The wire shape is the contract for external integrations (Claude Code skills, MCP, etc.); breaking it requires a `*.v2` major bump and parallel-running both for one phase. In `--json` mode, fatal errors emit `error.v1` to stderr as ndjson (non-`--json` mode keeps plain stderr text); exit codes 0/1/2/3 are unchanged — `error.v1.code` provides fine-grained agent branching.
|
||||
|
||||
In-tree integration packages live under `integrations/<host>/` — currently `integrations/claude-code/kebab/` (a Claude Code skill that calls `kebab search --json` / `kebab ask --json`). Any wire schema major bump (v1→v2) MUST update each shipped integration in the same PR, same as the version-cascade rule below. Per-user trigger keywords (team / system / acronym) belong in the user's local copy of the skill, not in the repo-shipped frontmatter — keep `integrations/claude-code/kebab/SKILL.md`'s `description` generic.
|
||||
|
||||
@@ -94,6 +94,7 @@ Release 절차:
|
||||
- XDG paths: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
|
||||
- SQLite filename: `kebab.sqlite` (under `data_dir`).
|
||||
- Workspace ignore: `.kebabignore` (per directory).
|
||||
- `_external/` (under `workspace.root`): single-file / stdin ingest 가 외부 file 을 deterministic 명명 (`<blake3-12>.<ext>`) 으로 copy. 첫 생성 시 `.kebabignore` 자동 append.
|
||||
|
||||
The migration from the old `kb` name lives in commits `911fb49 / f1a448d / f9714aa`. If you spot a leftover `kb` reference, treat it as a leftover and fix it (the rename PR sweep covered crates/, docs/, tasks/, README, design doc, fixtures — but workspace root `Cargo.toml` comments needed a follow-up; assume similar misses are possible).
|
||||
|
||||
|
||||
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -3525,11 +3525,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-app"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"dirs 5.0.1",
|
||||
"ignore",
|
||||
"image",
|
||||
"kebab-chunk",
|
||||
"kebab-config",
|
||||
@@ -3567,7 +3568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-chunk"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3582,7 +3583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3602,7 +3603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-config"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
@@ -3617,7 +3618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-core"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3631,7 +3632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3645,7 +3646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-local"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fastembed",
|
||||
@@ -3658,7 +3659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-eval"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -3677,7 +3678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -3686,7 +3687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm-local"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -3703,7 +3704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-mcp"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -3720,7 +3721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-normalize"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -3735,7 +3736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-image"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
@@ -3759,7 +3760,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-md"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -3776,7 +3777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3789,7 +3790,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-types"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"kebab-core",
|
||||
"serde",
|
||||
@@ -3797,7 +3798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-rag"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3818,7 +3819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-search"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
@@ -3836,7 +3837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-source-fs"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3853,7 +3854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-sqlite"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3874,7 +3875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-vector"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrow",
|
||||
@@ -3898,7 +3899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
|
||||
@@ -30,7 +30,7 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
|
||||
@@ -31,6 +31,11 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
- **2026-05-07 fb-26 (progress.rs)** — `Aborted` unconditional writeln (TTY duplicate) + `Completed` TTY no summary fixed; `KEBAB_PROGRESS=plain` env + quiet suppression added
|
||||
- **2026-05-07 fb-28 (main.rs)** — `--readonly` (KEBAB_READONLY) blocks Ingest/IngestFile/IngestStdin/Reset; `--quiet` suppresses progress stderr; error.v1 code: "readonly_mode"
|
||||
|
||||
- **2026-05-07 macOS XDG path collision (config 사라지는 버그)** — `dirs` crate 가 macOS 에서 `config_dir()` 과 `data_dir()` 둘 다 `~/Library/Application Support/` 반환 → `reset --data-only` 가 config 파일까지 삭제. Fix: `~/.config`, `~/.local/share`, `~/.cache` 직접 사용. 새 경로: config `~/.config/kebab/`, data `~/.local/share/kebab/`, cache `~/.cache/kebab/`. `Config::load(None)` 이 macOS legacy path 에서 자동 마이그레이션. 자세한 내용: `tasks/HOTFIXES.md`.
|
||||
- **2026-05-07 P9 post-도그푸딩 (p9-fb-31)** — `kebab ingest-file <path>` + `kebab ingest-stdin --title <T>` 두 신규 subcommand + MCP tool `ingest_file` / `ingest_stdin` (4 → 6 tool). agent 가 fetch 한 web markdown / 외부 file 을 KB 에 즉시 저장. workspace 외부 file 은 `<workspace.root>/_external/<blake3-12>.<ext>` 로 copy (deterministic 명명 → idempotent). `_external/` 디렉토리 첫 생성 시 `.kebabignore` 자동 append (walk 무한 루프 방지). stdin 은 markdown 전용 + flag (`--title`, `--source-uri`) → frontmatter 자동 prepend. .kebabignore 매치 시 stderr warn 후 진행 (explicit ingest = bypass intent). fb-30 의 v1 read-only MCP 정책 변경 — 첫 mutation tool 도입. spec: `tasks/p9/p9-fb-31-single-file-stdin-ingest.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-31-single-file-stdin-ingest-design.md`.
|
||||
- **2026-05-07 P9 post-도그푸딩 (p9-fb-30)** — `kebab mcp` 신규 subcommand + new crate `kebab-mcp` (lib only) — stdio JSON-RPC server. 4 read-only tool (`search` / `ask` / `schema` / `doctor`) 가 `kebab-app` facade 위에 build. rmcp 1.6 SDK 채택, manual `tools/list` + `tools/call` dispatch (rmcp 의 `#[tool_router]` 매크로 대신). `error_classify` 모듈을 `kebab-cli` → `kebab-app::error_wire` 로 promotion (UI crate 끼리 import 회피, facade 룰 준수). `ErrorV1` 에 `schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합. `KebabAppState` 가 `(Config, Option<PathBuf>)` carry — doctor tool 의 path-aware behavior 위해. ask + search arm 의 `tokio::task::spawn_blocking` wrap — `OllamaLanguageModel` 의 reqwest blocking client 가 async 안에서 panic 회피. capability flag `mcp_server` `false` → `true`. agent integration MVP 완성 — Claude Code / Cursor / OpenAI Agents 등 host-agnostic 사용 가능. spec: `tasks/p9/p9-fb-30-mcp-server.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
|
||||
- **P3-5 / P4-3 `--config` 누락** — `kebab-cli` 가 `--config <path>` 를 honor 하려면 `kebab_app::*_with_config` companion 을 호출해야 함. 두 번 같은 모양으로 회귀했음.
|
||||
- **P6-2 OCR 기본 엔진** — spec literal 의 Tesseract 가 시스템 dep 부담으로 거부됨, Ollama vision LM 으로 대체. `OcrEngine` trait 그대로라 future swap 가능.
|
||||
|
||||
14
README.md
14
README.md
@@ -80,10 +80,14 @@ kebab doctor
|
||||
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
|
||||
| `kebab eval run / compare` | golden query 회귀 측정 |
|
||||
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json` 은 `schema.v1` wire; 사람 모드는 서식 출력. |
|
||||
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor`). `--config` honor. |
|
||||
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
|
||||
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
|
||||
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |
|
||||
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 unchanged).
|
||||
|
||||
글로벌 플래그: `--readonly` (또는 `KEBAB_READONLY=1`) — 모든 write-path 명령 (`ingest` / `ingest-file` / `ingest-stdin` / `reset`) 을 비활성화, exit 1. `--quiet` — 진행 바 / hint 등 human-readable stderr 억제 (exit code / stdout 출력은 그대로). `KEBAB_PROGRESS=plain` — TTY 가 없는 환경에서도 진행 상황을 plain-text 한 줄씩 stderr 로 출력 (spinner 대신).
|
||||
|
||||
## 논리 아키텍처
|
||||
|
||||
```mermaid
|
||||
@@ -164,9 +168,11 @@ config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.tom
|
||||
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. `kebab mcp` 참조.
|
||||
- **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중).
|
||||
|
||||
## MCP 사용 (Claude Code 예시)
|
||||
## MCP 사용
|
||||
|
||||
`~/.claude/mcp.json` (또는 host 의 동등 위치):
|
||||
`kebab mcp` 가 stdio MCP server. 6 tool: `search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`.
|
||||
|
||||
Claude Code 빠른 등록 (`~/.claude/mcp.json` 또는 host 동등 위치):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -179,7 +185,7 @@ config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.tom
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code 가 session 시작 시 `kebab mcp` 를 spawn — process 가 session 동안 살아 있어 SQLite / Lance / fastembed 가 hot. 4 tool: `search` (lexical/vector/hybrid 검색), `ask` (RAG 답변, optional `session_id` for multi-turn + optional `mode` override), `schema` (capability 조회), `doctor` (health check). 모든 tool 의 결과는 wire schema v1 JSON 으로 text content 안에 직렬화 — agent 가 parse 후 사용. tool dispatch 실패 (잘못된 config / 미초기화 KB 등) 는 `isError: true` + error.v1 content; refusal / no-hit / unhealthy 는 정상 응답 (semantic flag 으로 분기).
|
||||
자세한 사용법 (Cursor / OpenAI Agents / Copilot CLI config, per-tool 입출력 예시, troubleshooting, multi-turn ask + session 관리, performance / security) — **[docs/mcp-usage.md](docs/mcp-usage.md)** 참조.
|
||||
|
||||
## 비-목표
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ lru = { workspace = true }
|
||||
# `" foo "` collapse to one entry. Same crate kebab-normalize +
|
||||
# kebab-core already use, no version drift.
|
||||
unicode-normalization = "0.1"
|
||||
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
|
||||
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.
|
||||
ignore = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
|
||||
253
crates/kebab-app/src/external.rs
Normal file
253
crates/kebab-app/src/external.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Helpers for the `_external/` workspace subdirectory used by
|
||||
//! `ingest_file_with_config` and `ingest_stdin_with_config` (p9-fb-31).
|
||||
//!
|
||||
//! - `ensure_external_dir`: create `<workspace.root>/_external/` if absent.
|
||||
//! - `ensure_kebabignore_entry`: append `_external/` to `<workspace.root>/.kebabignore`
|
||||
//! if missing — prevents subsequent `kebab ingest` workspace walks from
|
||||
//! re-walking files that were imported via single-file ingest.
|
||||
//! - `copy_to_external`: write bytes to `_external/<blake3-12>.<ext>`, idempotent.
|
||||
//! - `inject_frontmatter`: prepend a YAML frontmatter block to a markdown body
|
||||
//! string (used by `ingest_stdin_with_config`).
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub const EXTERNAL_DIR: &str = "_external";
|
||||
const KEBABIGNORE_LINE: &str = "_external/";
|
||||
|
||||
/// Ensure `<workspace_root>/_external/` exists. Returns the directory path.
|
||||
pub fn ensure_external_dir(workspace_root: &Path) -> Result<PathBuf> {
|
||||
let dir = workspace_root.join(EXTERNAL_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("create _external dir at {}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Append `_external/` line to `<workspace_root>/.kebabignore` if not already
|
||||
/// present. Idempotent — checks for the exact line before appending.
|
||||
pub fn ensure_kebabignore_entry(workspace_root: &Path) -> Result<()> {
|
||||
let path = workspace_root.join(".kebabignore");
|
||||
let existing = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.with_context(|| format!("read existing .kebabignore at {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let already = existing
|
||||
.lines()
|
||||
.any(|line| line.trim() == KEBABIGNORE_LINE);
|
||||
if already {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("open .kebabignore for append at {}", path.display()))?;
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
writeln!(file, "{}", KEBABIGNORE_LINE)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy bytes to `<external_dir>/<blake3-12>.<ext>`. Idempotent — if the
|
||||
/// destination file already exists with the expected hash, the existing
|
||||
/// file is reused (no second write). Returns the destination path.
|
||||
pub fn copy_to_external(
|
||||
external_dir: &Path,
|
||||
bytes: &[u8],
|
||||
ext: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let hash = blake3::hash(bytes);
|
||||
let hex = hash.to_hex();
|
||||
let prefix = &hex.as_str()[..12];
|
||||
let filename = format!("{prefix}.{ext}");
|
||||
let dest = external_dir.join(&filename);
|
||||
if !dest.exists() {
|
||||
fs::write(&dest, bytes)
|
||||
.with_context(|| format!("write external file at {}", dest.display()))?;
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Prepend a YAML frontmatter block to a markdown body. Returns the wrapped
|
||||
/// markdown string. Errors if `body` already starts with `---` (the user
|
||||
/// should use `ingest_file_with_config` for files that already carry
|
||||
/// frontmatter).
|
||||
///
|
||||
/// Internal `yaml_quote` always uses double-quoted YAML form with backslash
|
||||
/// escapes for `"` / `\` / control chars — agent-supplied titles with
|
||||
/// special characters are safe.
|
||||
pub fn inject_frontmatter(
|
||||
body: &str,
|
||||
title: &str,
|
||||
source_uri: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let head = body.trim_start();
|
||||
if head.starts_with("---\n") || head.starts_with("---\r\n") || head.starts_with("---\r") {
|
||||
anyhow::bail!(
|
||||
"stdin already has frontmatter; use `kebab ingest-file` for files with metadata"
|
||||
);
|
||||
}
|
||||
let title_yaml = yaml_quote(title);
|
||||
let mut header = String::new();
|
||||
header.push_str("---\n");
|
||||
header.push_str(&format!("title: {title_yaml}\n"));
|
||||
if let Some(uri) = source_uri {
|
||||
let uri_yaml = yaml_quote(uri);
|
||||
header.push_str(&format!("source_uri: {uri_yaml}\n"));
|
||||
}
|
||||
header.push_str("---\n\n");
|
||||
header.push_str(body);
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
/// YAML-quote a string. Always uses double-quoted form with backslash-escape
|
||||
/// for `"` and `\`. Defensive against agent-supplied titles that contain
|
||||
/// quotes / control chars.
|
||||
fn yaml_quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn ensure_external_dir_creates_dir() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = ensure_external_dir(dir.path()).unwrap();
|
||||
assert_eq!(result, dir.path().join("_external"));
|
||||
assert!(result.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_external_dir_is_idempotent() {
|
||||
let dir = tempdir().unwrap();
|
||||
let _ = ensure_external_dir(dir.path()).unwrap();
|
||||
let result = ensure_external_dir(dir.path()).unwrap();
|
||||
assert!(result.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_creates_file_with_line() {
|
||||
let dir = tempdir().unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
assert!(content.lines().any(|l| l.trim() == "_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_appends_to_existing() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join(".kebabignore"), "*.tmp\n").unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.contains(&"*.tmp"));
|
||||
assert!(lines.contains(&"_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_idempotent() {
|
||||
let dir = tempdir().unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
let count = content.lines().filter(|l| l.trim() == "_external/").count();
|
||||
assert_eq!(count, 1, "should not duplicate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_handles_missing_trailing_newline() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join(".kebabignore"), "*.tmp").unwrap(); // no \n
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.contains(&"*.tmp"));
|
||||
assert!(lines.contains(&"_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_external_writes_with_hash_prefix_filename() {
|
||||
let dir = tempdir().unwrap();
|
||||
let ext_dir = ensure_external_dir(dir.path()).unwrap();
|
||||
let path = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(path.file_name().unwrap().to_string_lossy().ends_with(".md"));
|
||||
let stem = path.file_stem().unwrap().to_string_lossy();
|
||||
assert_eq!(stem.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_external_is_idempotent_for_same_bytes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let ext_dir = ensure_external_dir(dir.path()).unwrap();
|
||||
let p1 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
let p2 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
assert_eq!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_external_different_bytes_produce_different_filenames() {
|
||||
let dir = tempdir().unwrap();
|
||||
let ext_dir = ensure_external_dir(dir.path()).unwrap();
|
||||
let p1 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
let p2 = copy_to_external(&ext_dir, b"world", "md").unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_basic() {
|
||||
let out = inject_frontmatter("## Body", "Article X", None).unwrap();
|
||||
assert!(out.starts_with("---\ntitle: \"Article X\"\n---\n\n## Body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_with_source_uri() {
|
||||
let out = inject_frontmatter("## Body", "X", Some("https://example.com/x")).unwrap();
|
||||
assert!(out.contains("title: \"X\""));
|
||||
assert!(out.contains("source_uri: \"https://example.com/x\""));
|
||||
assert!(out.contains("\n## Body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_errors_on_existing_frontmatter() {
|
||||
let body = "---\ntitle: Existing\n---\n\n## Body";
|
||||
let err = inject_frontmatter(body, "New", None).unwrap_err();
|
||||
assert!(err.to_string().contains("already has frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_errors_on_existing_frontmatter_crlf() {
|
||||
let body = "---\r\ntitle: Existing\r\n---\r\n\r\n## Body";
|
||||
let err = inject_frontmatter(body, "New", None).unwrap_err();
|
||||
assert!(err.to_string().contains("already has frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_quote_escapes_quotes_and_backslashes() {
|
||||
assert_eq!(yaml_quote("hello \"world\""), "\"hello \\\"world\\\"\"");
|
||||
assert_eq!(yaml_quote("path\\to"), "\"path\\\\to\"");
|
||||
assert_eq!(yaml_quote("line\nbreak"), "\"line\\nbreak\"");
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ mod app;
|
||||
pub mod doctor_signal;
|
||||
pub mod error_signal;
|
||||
pub mod error_wire;
|
||||
pub mod external;
|
||||
pub mod ingest_progress;
|
||||
pub mod logging;
|
||||
pub mod reset;
|
||||
@@ -1874,3 +1875,143 @@ pub fn doctor_with_config_path(config_path: Option<&std::path::Path>) -> anyhow:
|
||||
pub fn doctor() -> anyhow::Result<DoctorReport> {
|
||||
doctor_with_config_path(None)
|
||||
}
|
||||
|
||||
/// Single-file ingest (p9-fb-31). Copies the file to
|
||||
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
|
||||
/// per-medium ingest pipeline on that single asset. Returns an
|
||||
/// `IngestReport` with `scanned: 1` (and either `new: 1` or
|
||||
/// `unchanged: 1` depending on whether the content hash + version
|
||||
/// cascade match an existing doc — incremental ingest from p9-fb-23).
|
||||
///
|
||||
/// `path` may point inside or outside the workspace.
|
||||
///
|
||||
/// `.kebabignore` patterns matching `path` are bypassed with a stderr
|
||||
/// `warn:` line — explicit ingest is intent.
|
||||
#[doc(hidden)]
|
||||
pub fn ingest_file_with_config(
|
||||
config: kebab_config::Config,
|
||||
path: &std::path::Path,
|
||||
) -> anyhow::Result<IngestReport> {
|
||||
if !path.exists() {
|
||||
anyhow::bail!("ingest-file: source path does not exist: {}", path.display());
|
||||
}
|
||||
if !path.is_file() {
|
||||
anyhow::bail!("ingest-file: not a regular file: {}", path.display());
|
||||
}
|
||||
|
||||
let ext_raw = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("ingest-file: source has no extension: {}", path.display()))?;
|
||||
let ext = ext_raw.to_lowercase();
|
||||
|
||||
const SUPPORTED_EXTS: &[&str] = &["md", "pdf", "png", "jpg", "jpeg"];
|
||||
if !SUPPORTED_EXTS.contains(&ext.as_str()) {
|
||||
anyhow::bail!(
|
||||
"ingest-file: unsupported extension `.{}` (supported: {:?})",
|
||||
ext, SUPPORTED_EXTS
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(path)
|
||||
.with_context(|| format!("ingest-file: read source {}", path.display()))?;
|
||||
|
||||
let workspace_root = config.resolve_workspace_root();
|
||||
|
||||
// .kebabignore check — warn but continue.
|
||||
let ignore_match = check_kebabignore_match(&workspace_root, path);
|
||||
if ignore_match {
|
||||
eprintln!(
|
||||
"warn: {} matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Set up _external/ dir + auto-ignore line.
|
||||
let external_dir = crate::external::ensure_external_dir(&workspace_root)
|
||||
.context("ingest-file: ensure _external/ dir")?;
|
||||
crate::external::ensure_kebabignore_entry(&workspace_root)
|
||||
.context("ingest-file: append _external/ to .kebabignore")?;
|
||||
|
||||
// Copy bytes to _external/<hash>.<ext>.
|
||||
let dest = crate::external::copy_to_external(&external_dir, &bytes, &ext)
|
||||
.context("ingest-file: copy to _external")?;
|
||||
|
||||
// Build a SourceScope that targets _external/ with include filter
|
||||
// restricting walk to the single dest filename.
|
||||
let filename = dest
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("ingest-file: dest has no filename"))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let scope = kebab_core::SourceScope {
|
||||
root: external_dir.clone(),
|
||||
include: vec![filename],
|
||||
exclude: config.workspace.exclude.clone(),
|
||||
};
|
||||
|
||||
let opts = IngestOpts::default();
|
||||
ingest_with_config_opts(config, scope, /* summary_only = */ false, opts)
|
||||
}
|
||||
|
||||
/// Stdin ingest (p9-fb-31, v1 markdown only). Prepends a YAML
|
||||
/// frontmatter block (`title` + optional `source_uri`) to `body`,
|
||||
/// writes the wrapped markdown to `_external/<hash12>.md`, and runs
|
||||
/// `ingest_file_with_config` on the resulting file.
|
||||
///
|
||||
/// Errors if `body` already starts with `---` (the user should call
|
||||
/// `ingest_file_with_config` directly for files that already carry
|
||||
/// frontmatter).
|
||||
#[doc(hidden)]
|
||||
pub fn ingest_stdin_with_config(
|
||||
config: kebab_config::Config,
|
||||
body: &str,
|
||||
title: &str,
|
||||
source_uri: Option<&str>,
|
||||
) -> anyhow::Result<IngestReport> {
|
||||
let wrapped = crate::external::inject_frontmatter(body, title, source_uri)?;
|
||||
|
||||
let workspace_root = config.resolve_workspace_root();
|
||||
// Note: ensure_external_dir + ensure_kebabignore_entry + copy_to_external
|
||||
// are called here AND inside ingest_file_with_config. All three are
|
||||
// idempotent; the redundancy is intentional — keeping stdin's wrapped
|
||||
// bytes accessible by `ingest_file_with_config` requires the dest path
|
||||
// to exist. The ~ms double-stat overhead is negligible at v1 scale.
|
||||
let external_dir = crate::external::ensure_external_dir(&workspace_root)?;
|
||||
crate::external::ensure_kebabignore_entry(&workspace_root)?;
|
||||
|
||||
let dest = crate::external::copy_to_external(
|
||||
&external_dir,
|
||||
wrapped.as_bytes(),
|
||||
"md",
|
||||
)?;
|
||||
|
||||
ingest_file_with_config(config, &dest)
|
||||
}
|
||||
|
||||
/// Returns true if `source_path` matches any `.kebabignore` pattern
|
||||
/// rooted at `workspace_root`. Used by `ingest_file_with_config` to
|
||||
/// emit a stderr warn before bypassing the ignore.
|
||||
fn check_kebabignore_match(workspace_root: &std::path::Path, source_path: &std::path::Path) -> bool {
|
||||
let kebabignore = workspace_root.join(".kebabignore");
|
||||
if !kebabignore.exists() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::fs::read_to_string(&kebabignore) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(workspace_root);
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let _ = builder.add_line(None, line);
|
||||
}
|
||||
let matcher = match builder.build() {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
matcher.matched(source_path, source_path.is_dir()).is_ignore()
|
||||
}
|
||||
|
||||
111
crates/kebab-app/tests/ingest_file.rs
Normal file
111
crates/kebab-app/tests/ingest_file.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Integration: kebab_app::ingest_file_with_config copies external file
|
||||
//! to _external/, ingests as single asset, idempotent on second call.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
#[test]
|
||||
fn ingest_file_copies_external_md_and_reports_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
// Source file outside the workspace.
|
||||
let external_src = dir.path().join("source.md");
|
||||
fs::write(&external_src, "# Hello\n\nbody.").unwrap();
|
||||
|
||||
let report = kebab_app::ingest_file_with_config(cfg.clone(), &external_src).unwrap();
|
||||
assert_eq!(report.scanned, 1, "{report:?}");
|
||||
assert_eq!(report.new, 1, "{report:?}");
|
||||
assert_eq!(report.unchanged, 0, "{report:?}");
|
||||
|
||||
// _external/ dir created, file copied with hash prefix.
|
||||
let ext_dir = workspace.join("_external");
|
||||
assert!(ext_dir.is_dir());
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1, "exactly one file in _external/");
|
||||
let name = entries[0].file_name().to_string_lossy().into_owned();
|
||||
assert!(name.ends_with(".md"));
|
||||
|
||||
// .kebabignore has _external/ line.
|
||||
let ki = fs::read_to_string(workspace.join(".kebabignore")).unwrap();
|
||||
assert!(ki.lines().any(|l| l.trim() == "_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_file_idempotent_on_second_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
fs::write(&src, "# A\n\nbody.").unwrap();
|
||||
|
||||
let r1 = kebab_app::ingest_file_with_config(cfg.clone(), &src).unwrap();
|
||||
assert_eq!(r1.new, 1);
|
||||
|
||||
let r2 = kebab_app::ingest_file_with_config(cfg.clone(), &src).unwrap();
|
||||
assert_eq!(r2.new, 0, "{r2:?}");
|
||||
assert_eq!(r2.unchanged, 1, "{r2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_file_errors_on_missing_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let nonexistent = dir.path().join("nope.md");
|
||||
let err = kebab_app::ingest_file_with_config(cfg, &nonexistent).unwrap_err();
|
||||
assert!(err.to_string().contains("does not exist"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_file_errors_on_unsupported_extension() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let docx = dir.path().join("doc.docx");
|
||||
fs::write(&docx, b"fake docx bytes").unwrap();
|
||||
|
||||
let err = kebab_app::ingest_file_with_config(cfg, &docx).unwrap_err();
|
||||
assert!(err.to_string().contains("unsupported extension"), "{err}");
|
||||
assert!(err.to_string().contains(".docx") || err.to_string().contains("docx"), "{err}");
|
||||
}
|
||||
78
crates/kebab-app/tests/ingest_stdin.rs
Normal file
78
crates/kebab-app/tests/ingest_stdin.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Integration: kebab_app::ingest_stdin_with_config injects frontmatter,
|
||||
//! writes to _external/, ingests as single asset.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
fn fresh_cfg(dir: &std::path::Path) -> Config {
|
||||
let workspace = dir.join("notes");
|
||||
let data = dir.join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_stdin_writes_frontmatter_and_reports_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = fresh_cfg(dir.path());
|
||||
|
||||
let report = kebab_app::ingest_stdin_with_config(
|
||||
cfg.clone(),
|
||||
"## Body content\n\nMore.",
|
||||
"Article X",
|
||||
Some("https://example.com/x"),
|
||||
).unwrap();
|
||||
assert_eq!(report.new, 1, "{report:?}");
|
||||
|
||||
// _external/ contains exactly one .md file with frontmatter.
|
||||
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let content = fs::read_to_string(entries[0].path()).unwrap();
|
||||
assert!(content.starts_with("---\n"));
|
||||
assert!(content.contains("title: \"Article X\""));
|
||||
assert!(content.contains("source_uri: \"https://example.com/x\""));
|
||||
assert!(content.contains("## Body content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_stdin_without_source_uri() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = fresh_cfg(dir.path());
|
||||
|
||||
let report = kebab_app::ingest_stdin_with_config(
|
||||
cfg.clone(),
|
||||
"## Body",
|
||||
"Title",
|
||||
None,
|
||||
).unwrap();
|
||||
assert_eq!(report.new, 1);
|
||||
|
||||
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
let content = fs::read_to_string(entries[0].path()).unwrap();
|
||||
assert!(content.contains("title: \"Title\""));
|
||||
assert!(!content.contains("source_uri"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_stdin_errors_on_existing_frontmatter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = fresh_cfg(dir.path());
|
||||
|
||||
let body = "---\ntitle: Already\n---\n\n## Body";
|
||||
let err = kebab_app::ingest_stdin_with_config(cfg, body, "New", None).unwrap_err();
|
||||
assert!(err.to_string().contains("already has frontmatter"), "{err}");
|
||||
}
|
||||
@@ -32,7 +32,7 @@ kebab-mcp = { path = "../kebab-mcp" }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
# p9-fb-02: ingest progress UI.
|
||||
# - TTY 사람 모드: indicatif spinner + bar (stderr).
|
||||
# - --json 모드 / non-TTY: indicatif 끄고 raw line emit.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! `kb` — command-line interface. Each subcommand maps 1:1 to a `kb-app`
|
||||
//! `kebab` — command-line interface. Each subcommand maps 1:1 to a `kebab-app`
|
||||
//! function. Exit codes per design §10.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||
@@ -31,6 +32,16 @@ struct Cli {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
|
||||
/// Disable all write-path subcommands (also: KEBAB_READONLY=1 env var).
|
||||
#[arg(long, global = true, env = "KEBAB_READONLY",
|
||||
value_parser = parse_bool_env)]
|
||||
readonly: bool,
|
||||
|
||||
/// Suppress all human-readable stderr output: progress lines, hints.
|
||||
/// Implied by `--json`.
|
||||
#[arg(long, global = true)]
|
||||
quiet: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Cmd,
|
||||
}
|
||||
@@ -139,7 +150,7 @@ enum Cmd {
|
||||
/// history and appends the new Q/A. Without this flag, ask
|
||||
/// is single-shot (no persistence). The session id is
|
||||
/// caller-supplied — pick anything stable per conversation
|
||||
/// (e.g. `kb-rust-async-2026-05`).
|
||||
/// (e.g. `kebab-rust-async-2026-05`).
|
||||
#[arg(long, value_name = "ID")]
|
||||
session: Option<String>,
|
||||
},
|
||||
@@ -193,6 +204,24 @@ enum Cmd {
|
||||
/// agent hosts (Claude Code / Cursor / OpenAI Agents) to call kebab
|
||||
/// tools (search / ask / schema / doctor).
|
||||
Mcp,
|
||||
|
||||
/// Ingest a single file (workspace external paths allowed).
|
||||
/// Bytes are copied into `<workspace.root>/_external/<hash>.<ext>`.
|
||||
IngestFile {
|
||||
/// File path to ingest.
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
|
||||
/// Ingest markdown content from stdin. v1 markdown only.
|
||||
/// Frontmatter (title + source_uri) is auto-injected.
|
||||
IngestStdin {
|
||||
/// Title — required, written to frontmatter.
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
/// Source URI — optional, written to frontmatter when present.
|
||||
#[arg(long)]
|
||||
source_uri: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -266,6 +295,16 @@ impl From<ModeFlag> for kebab_core::SearchMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse boolean env var accepting "1", "true", "yes", "on" (case-insensitive)
|
||||
/// as truthy; "0", "false", "no", "off" as falsy. Used for `KEBAB_READONLY`.
|
||||
fn parse_bool_env(s: &str) -> Result<bool, String> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
other => Err(format!("expected 1/0/true/false/yes/no/on/off, got {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
let level = if cli.debug {
|
||||
@@ -276,8 +315,30 @@ fn main() -> ExitCode {
|
||||
kebab_app::logging::LogLevel::Default
|
||||
};
|
||||
// Fail-soft: if logging init errors (e.g. XDG state dir is read-only),
|
||||
// proceed without a guard rather than crashing — `kb` is still usable.
|
||||
// proceed without a guard rather than crashing — `kebab` is still usable.
|
||||
let _log_guard = kebab_app::logging::init(level).ok();
|
||||
if cli.readonly && is_mutating(&cli.command) {
|
||||
let msg = "kebab: readonly mode — mutating commands are disabled";
|
||||
if cli.json {
|
||||
let v1 = kebab_app::ErrorV1 {
|
||||
schema_version: kebab_app::ERROR_V1_ID.to_string(),
|
||||
code: "readonly_mode".to_string(),
|
||||
message: msg.to_string(),
|
||||
details: serde_json::json!({}),
|
||||
hint: Some(
|
||||
"remove --readonly (or unset KEBAB_READONLY) to allow writes".to_string(),
|
||||
),
|
||||
};
|
||||
let v = wire::wire_error_v1(&v1);
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(&v).unwrap_or_else(|_| msg.to_string())
|
||||
);
|
||||
} else {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
match run(&cli) {
|
||||
Ok(()) => ExitCode::from(0),
|
||||
Err(e) => {
|
||||
@@ -329,7 +390,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
);
|
||||
println!("created {}", kebab_config::Config::xdg_data_dir().display());
|
||||
println!("created {}", kebab_config::Config::xdg_state_dir().display());
|
||||
println!("hint edit the config above, then `kb ingest`");
|
||||
println!("hint edit the config above, then `kebab ingest`");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -351,7 +412,10 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
// the channel and emits per-step events into it. When the
|
||||
// call returns, the `Sender` drops and the display thread
|
||||
// sees `recv()` return Err — exits cleanly.
|
||||
let mode = progress::ProgressMode::from_flags(cli.json);
|
||||
let plain_env = std::env::var("KEBAB_PROGRESS")
|
||||
.map(|v| v.eq_ignore_ascii_case("plain"))
|
||||
.unwrap_or(false);
|
||||
let mode = progress::ProgressMode::from_flags(cli.json, cli.quiet, plain_env);
|
||||
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
|
||||
let display_handle = std::thread::spawn(move || {
|
||||
progress::ProgressDisplay::new(mode).run(rx)
|
||||
@@ -595,7 +659,9 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
if !confirm_destructive(scope, &paths, bytes)? {
|
||||
eprintln!("aborted.");
|
||||
if !cli.quiet {
|
||||
eprintln!("aborted.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -745,6 +811,48 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::IngestFile { path } => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let report = kebab_app::ingest_file_with_config(cfg, path)?;
|
||||
if cli.json {
|
||||
let v = wire::wire_ingest(&report);
|
||||
println!("{}", serde_json::to_string(&v)?);
|
||||
} else {
|
||||
println!(
|
||||
"ingest-file: scanned={} new={} updated={} unchanged={} skipped={} errors={}",
|
||||
report.scanned, report.new, report.updated,
|
||||
report.unchanged, report.skipped, report.errors
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::IngestStdin { title, source_uri } => {
|
||||
use std::io::Read;
|
||||
let mut body = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut body)
|
||||
.context("kebab ingest-stdin: read stdin")?;
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let report = kebab_app::ingest_stdin_with_config(
|
||||
cfg,
|
||||
&body,
|
||||
title,
|
||||
source_uri.as_deref(),
|
||||
)?;
|
||||
if cli.json {
|
||||
let v = wire::wire_ingest(&report);
|
||||
println!("{}", serde_json::to_string(&v)?);
|
||||
} else {
|
||||
println!(
|
||||
"ingest-stdin: scanned={} new={} updated={} unchanged={} skipped={} errors={}",
|
||||
report.scanned, report.new, report.updated,
|
||||
report.unchanged, report.skipped, report.errors
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Mcp => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
kebab_mcp::serve_stdio(cfg, cli.config.clone())
|
||||
@@ -796,6 +904,13 @@ fn print_schema_text(s: &kebab_app::SchemaV1) {
|
||||
println!(" last_ingest_at {last}");
|
||||
}
|
||||
|
||||
fn is_mutating(cmd: &Cmd) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
Cmd::Ingest { .. } | Cmd::IngestFile { .. } | Cmd::IngestStdin { .. } | Cmd::Reset { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal stdin/stdout confirm prompt for destructive ops. No new dep —
|
||||
/// uses stdlib `IsTerminal` (the caller is expected to have already
|
||||
/// short-circuited the non-TTY case). Returns `Ok(true)` only when the
|
||||
|
||||
@@ -39,18 +39,22 @@ pub enum ProgressMode {
|
||||
Json,
|
||||
/// stdout reserved for the final report; stderr gets an indicatif
|
||||
/// `ProgressBar` (TTY) or one short line per event (non-TTY).
|
||||
Human { tty: bool },
|
||||
Human { tty: bool, quiet: bool },
|
||||
}
|
||||
|
||||
impl ProgressMode {
|
||||
/// Pick the right mode from caller flags.
|
||||
pub fn from_flags(json: bool) -> Self {
|
||||
///
|
||||
/// - `json`: `--json` flag — takes priority, returns `Json`.
|
||||
/// - `quiet`: `--quiet` flag — suppresses human-readable stderr when `Human`.
|
||||
/// - `plain_env`: `KEBAB_PROGRESS=plain` — forces `tty=false` even in a TTY,
|
||||
/// for CI environments that emulate a TTY with a pty wrapper.
|
||||
pub fn from_flags(json: bool, quiet: bool, plain_env: bool) -> Self {
|
||||
if json {
|
||||
Self::Json
|
||||
} else {
|
||||
Self::Human {
|
||||
tty: std::io::stderr().is_terminal(),
|
||||
}
|
||||
let tty = !plain_env && std::io::stderr().is_terminal();
|
||||
Self::Human { tty, quiet }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +87,7 @@ impl ProgressDisplay {
|
||||
fn handle(&mut self, event: &IngestEvent) -> anyhow::Result<()> {
|
||||
match self.mode {
|
||||
ProgressMode::Json => emit_json(event),
|
||||
ProgressMode::Human { tty } => self.handle_human(event, tty),
|
||||
ProgressMode::Human { tty, quiet } => self.handle_human(event, tty, quiet),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +100,20 @@ impl ProgressDisplay {
|
||||
/// `ScanStarted` arm and §2.4a's ordering invariant
|
||||
/// (`ScanStarted` < everything else) guarantees it is `Some` by
|
||||
/// the time later events arrive.
|
||||
fn handle_human(&mut self, event: &IngestEvent, tty: bool) -> anyhow::Result<()> {
|
||||
fn handle_human(&mut self, event: &IngestEvent, tty: bool, quiet: bool) -> anyhow::Result<()> {
|
||||
match event {
|
||||
IngestEvent::ScanStarted { root } => {
|
||||
let bar = ProgressBar::new_spinner().with_message(format!("scanning {root}"));
|
||||
bar.set_draw_target(if tty {
|
||||
bar.set_draw_target(if tty && !quiet {
|
||||
ProgressDrawTarget::stderr()
|
||||
} else {
|
||||
ProgressDrawTarget::hidden()
|
||||
});
|
||||
bar.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
if tty && !quiet {
|
||||
bar.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.bar = Some(bar);
|
||||
if !tty {
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: scanning {root}…");
|
||||
}
|
||||
@@ -126,7 +132,7 @@ impl ProgressDisplay {
|
||||
);
|
||||
bar.set_message("");
|
||||
}
|
||||
if !tty {
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: scan complete ({total} assets)");
|
||||
}
|
||||
@@ -138,23 +144,28 @@ impl ProgressDisplay {
|
||||
media,
|
||||
} => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message(format!("{media} {path}"));
|
||||
// One draw per file: position only. set_message() would
|
||||
// trigger a second independent draw and pollute TTY scrollback.
|
||||
// Filename is visible in the non-TTY plain-line path below.
|
||||
bar.set_position(u64::from(idx.saturating_sub(1)));
|
||||
}
|
||||
if !tty {
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} {media} {path}");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetFinished { idx, .. } => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_position(u64::from(*idx));
|
||||
}
|
||||
IngestEvent::AssetFinished { .. } => {
|
||||
// Position is advanced in AssetStarted; bar.finish_and_clear()
|
||||
// in Completed handles the final state. No per-asset bar update
|
||||
// here avoids the duplicate-frame artifact in TTY scrollback.
|
||||
}
|
||||
IngestEvent::Completed { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if !tty {
|
||||
// Always emit summary in both TTY and non-TTY (unless quiet).
|
||||
// Bug fix: previously TTY had no summary line after bar.finish_and_clear().
|
||||
if !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
@@ -175,16 +186,20 @@ impl ProgressDisplay {
|
||||
counts.scanned
|
||||
));
|
||||
}
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"ingest: aborted (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned,
|
||||
counts.new,
|
||||
counts.updated,
|
||||
counts.skipped,
|
||||
counts.errors,
|
||||
);
|
||||
// Bug fix: was unconditional (fired in TTY too).
|
||||
// In TTY, bar.abandon_with_message already prints the final state.
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"ingest: aborted (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned,
|
||||
counts.new,
|
||||
counts.updated,
|
||||
counts.skipped,
|
||||
counts.errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -216,20 +231,35 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_flags_json_takes_priority_over_tty() {
|
||||
// --json forces Json regardless of TTY state.
|
||||
assert_eq!(ProgressMode::from_flags(true), ProgressMode::Json);
|
||||
assert_eq!(ProgressMode::from_flags(true, false, false), ProgressMode::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_flags_human_reflects_stderr_tty() {
|
||||
// We can't synthesize a TTY in tests, but we can assert the
|
||||
// shape — mode is Human { tty: <something> } when --json=false.
|
||||
match ProgressMode::from_flags(false) {
|
||||
match ProgressMode::from_flags(false, false, false) {
|
||||
ProgressMode::Human { .. } => {}
|
||||
other => panic!("expected Human mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_flags_quiet_sets_quiet_field() {
|
||||
match ProgressMode::from_flags(false, true, false) {
|
||||
ProgressMode::Human { quiet: true, .. } => {}
|
||||
other => panic!("expected Human{{quiet:true}}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_flags_plain_env_forces_tty_false() {
|
||||
match ProgressMode::from_flags(false, false, true) {
|
||||
ProgressMode::Human { tty: false, .. } => {}
|
||||
other => panic!("expected Human{{tty:false}}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_rfc3339_parses_back() {
|
||||
let s = now_rfc3339().unwrap();
|
||||
|
||||
92
crates/kebab-cli/tests/cli_ingest_file.rs
Normal file
92
crates/kebab-cli/tests/cli_ingest_file.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Integration: spawn `kebab ingest-file <path>` and verify ingest_report.v1.
|
||||
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn cli_ingest_file_emits_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let cfg_path = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg_path,
|
||||
format!(
|
||||
r#"schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = "{workspace}"
|
||||
exclude = [".git/**"]
|
||||
|
||||
[storage]
|
||||
data_dir = "{data}"
|
||||
sqlite = "{{data_dir}}/kebab.sqlite"
|
||||
vector_dir = "{{data_dir}}/lancedb"
|
||||
asset_dir = "{{data_dir}}/assets"
|
||||
artifact_dir = "{{data_dir}}/artifacts"
|
||||
model_dir = "{{data_dir}}/models"
|
||||
runs_dir = "{{data_dir}}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
[chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "none"
|
||||
model = "none"
|
||||
version = "v0"
|
||||
dimensions = 0
|
||||
batch_size = 1
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
model = "none"
|
||||
context_tokens = 4096
|
||||
endpoint = "http://127.0.0.1:11434"
|
||||
temperature = 0.0
|
||||
seed = 0
|
||||
|
||||
[search]
|
||||
default_k = 10
|
||||
hybrid_fusion = "rrf"
|
||||
rrf_k = 60
|
||||
snippet_chars = 220
|
||||
|
||||
[rag]
|
||||
prompt_template_version = "rag-v1"
|
||||
score_gate = 0.30
|
||||
explain_default = false
|
||||
max_context_tokens = 8000
|
||||
"#,
|
||||
workspace = workspace.display(),
|
||||
data = data.display(),
|
||||
),
|
||||
).unwrap();
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
fs::write(&src, "# A\n\nbody.").unwrap();
|
||||
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let out = Command::new(bin)
|
||||
.args(["--json", "--config", cfg_path.to_str().unwrap(), "ingest-file"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
100
crates/kebab-cli/tests/cli_ingest_stdin.rs
Normal file
100
crates/kebab-cli/tests/cli_ingest_stdin.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! Integration: spawn `kebab ingest-stdin --title X` with stdin pipe.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn cli_ingest_stdin_emits_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let cfg_path = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg_path,
|
||||
format!(
|
||||
r#"schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = "{workspace}"
|
||||
exclude = [".git/**"]
|
||||
|
||||
[storage]
|
||||
data_dir = "{data}"
|
||||
sqlite = "{{data_dir}}/kebab.sqlite"
|
||||
vector_dir = "{{data_dir}}/lancedb"
|
||||
asset_dir = "{{data_dir}}/assets"
|
||||
artifact_dir = "{{data_dir}}/artifacts"
|
||||
model_dir = "{{data_dir}}/models"
|
||||
runs_dir = "{{data_dir}}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
[chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "none"
|
||||
model = "none"
|
||||
version = "v0"
|
||||
dimensions = 0
|
||||
batch_size = 1
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
model = "none"
|
||||
context_tokens = 4096
|
||||
endpoint = "http://127.0.0.1:11434"
|
||||
temperature = 0.0
|
||||
seed = 0
|
||||
|
||||
[search]
|
||||
default_k = 10
|
||||
hybrid_fusion = "rrf"
|
||||
rrf_k = 60
|
||||
snippet_chars = 220
|
||||
|
||||
[rag]
|
||||
prompt_template_version = "rag-v1"
|
||||
score_gate = 0.30
|
||||
explain_default = false
|
||||
max_context_tokens = 8000
|
||||
"#,
|
||||
workspace = workspace.display(),
|
||||
data = data.display(),
|
||||
),
|
||||
).unwrap();
|
||||
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let mut child = Command::new(bin)
|
||||
.args([
|
||||
"--json", "--config", cfg_path.to_str().unwrap(),
|
||||
"ingest-stdin", "--title", "X",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
stdin.write_all(b"## Body\n\nbody text.\n").unwrap();
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
@@ -66,8 +66,8 @@ fn cli_mcp_initialize_then_tools_list() {
|
||||
.expect("tools/list result.tools must be an array");
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
4,
|
||||
"expected 4 tools (schema, doctor, search, ask), got {}: {list}",
|
||||
6,
|
||||
"expected 6 tools (schema, doctor, search, ask, ingest_file, ingest_stdin), got {}: {list}",
|
||||
tools.len()
|
||||
);
|
||||
|
||||
|
||||
183
crates/kebab-cli/tests/cli_readonly_quiet.rs
Normal file
183
crates/kebab-cli/tests/cli_readonly_quiet.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! Integration tests for `--readonly` and `--quiet` global flags (fb-28).
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
|
||||
fn kebab_bin() -> std::path::PathBuf {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
std::path::PathBuf::from(manifest)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target/debug/kebab")
|
||||
}
|
||||
|
||||
fn fixture_workspace() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&ws).unwrap();
|
||||
let mut a = std::fs::File::create(ws.join("a.md")).unwrap();
|
||||
writeln!(a, "# Alpha\n\nfirst doc").unwrap();
|
||||
(tmp, ws)
|
||||
}
|
||||
|
||||
fn xdg_envs(tmp_path: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] {
|
||||
[
|
||||
("XDG_CONFIG_HOME", tmp_path.join("cfg")),
|
||||
("XDG_DATA_HOME", tmp_path.join("data")),
|
||||
("XDG_CACHE_HOME", tmp_path.join("cache")),
|
||||
("XDG_STATE_HOME", tmp_path.join("state")),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_ingest() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("readonly mode"),
|
||||
"expected 'readonly mode' in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_ingest_file() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let file = ws.join("a.md");
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "ingest-file", file.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_ingest_stdin() {
|
||||
let (tmp, _ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "ingest-stdin", "--title", "test"])
|
||||
.env("KEBAB_READONLY", "1")
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_reset() {
|
||||
let (tmp, _ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "reset", "--data-only", "--yes"])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kebab_readonly_env_blocks_ingest() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["ingest", "--root", ws.to_str().unwrap()])
|
||||
.env("KEBAB_READONLY", "1")
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_json_mode_emits_error_v1() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "--json", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let v: serde_json::Value = serde_json::from_str(stderr.trim())
|
||||
.unwrap_or_else(|e| panic!("expected error.v1 JSON on stderr, got {stderr:?}: {e}"));
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("error.v1"),
|
||||
"expected schema_version=error.v1"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("code").and_then(|s| s.as_str()),
|
||||
Some("readonly_mode"),
|
||||
"expected code=readonly_mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_flag_suppresses_progress_stderr() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--quiet", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"exit: {:?}, stderr: {}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"expected empty stderr with --quiet, got: {stderr}"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("scanned"),
|
||||
"expected report summary on stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_with_json_stdout_has_report_stderr_is_empty() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--quiet", "--json", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.is_empty(), "expected empty stderr, got: {stderr}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let last_line = stdout.lines().last().unwrap_or("");
|
||||
let v: serde_json::Value = serde_json::from_str(last_line)
|
||||
.unwrap_or_else(|e| panic!("expected JSON on stdout last line, got {last_line:?}: {e}"));
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
}
|
||||
@@ -162,3 +162,32 @@ fn ingest_json_progress_lines_carry_kind_and_ts() {
|
||||
assert!(saw_scan_started, "missing scan_started event");
|
||||
assert!(saw_completed, "missing completed event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kebab_progress_plain_env_emits_append_lines() {
|
||||
// KEBAB_PROGRESS=plain forces non-TTY branch even in TTY-emulated envs.
|
||||
// In subprocess tests there's no TTY anyway, so this primarily verifies
|
||||
// the env var is accepted and the non-TTY path still works.
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["ingest", "--root", ws.to_str().unwrap()])
|
||||
.env("KEBAB_PROGRESS", "plain")
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("ingest: scanning"),
|
||||
"expected 'ingest: scanning' in stderr, got: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("ingest: complete"),
|
||||
"expected 'ingest: complete' in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -393,6 +393,25 @@ impl Config {
|
||||
if p.exists() {
|
||||
Self::from_file(&p)?
|
||||
} else {
|
||||
// macOS migration: if the new XDG path is absent but the
|
||||
// old ~/Library/Application Support/kebab/config.toml exists,
|
||||
// copy it to the new location so the user doesn't lose settings.
|
||||
if let Some(legacy) = Self::macos_legacy_config_path() {
|
||||
if legacy.exists() && !p.exists() {
|
||||
if let Some(parent) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if std::fs::copy(&legacy, &p).is_ok() {
|
||||
eprintln!(
|
||||
"kebab: migrated config {} → {}",
|
||||
legacy.display(),
|
||||
p.display()
|
||||
);
|
||||
return Self::from_file(&p)
|
||||
.map(|c| c.apply_env(&std::env::vars().collect()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::defaults()
|
||||
}
|
||||
}
|
||||
@@ -634,8 +653,11 @@ impl Config {
|
||||
return PathBuf::from(custom).join("kebab").join("config.toml");
|
||||
}
|
||||
}
|
||||
match dirs::config_dir() {
|
||||
Some(d) => d.join("kebab").join("config.toml"),
|
||||
// Always use XDG-standard ~/.config regardless of platform.
|
||||
// macOS dirs::config_dir() returns ~/Library/Application Support which
|
||||
// collides with data_dir() — DataOnly reset would delete config too.
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".config").join("kebab").join("config.toml"),
|
||||
None => PathBuf::from("./kebab/config.toml"),
|
||||
}
|
||||
}
|
||||
@@ -647,8 +669,9 @@ impl Config {
|
||||
return PathBuf::from(custom).join("kebab");
|
||||
}
|
||||
}
|
||||
match dirs::data_dir() {
|
||||
Some(d) => d.join("kebab"),
|
||||
// Always use XDG-standard ~/.local/share regardless of platform.
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".local").join("share").join("kebab"),
|
||||
None => PathBuf::from("./kebab-data"),
|
||||
}
|
||||
}
|
||||
@@ -660,8 +683,9 @@ impl Config {
|
||||
return PathBuf::from(custom).join("kebab");
|
||||
}
|
||||
}
|
||||
match dirs::cache_dir() {
|
||||
Some(d) => d.join("kebab"),
|
||||
// Always use XDG-standard ~/.cache regardless of platform.
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".cache").join("kebab"),
|
||||
None => PathBuf::from("./kebab-cache"),
|
||||
}
|
||||
}
|
||||
@@ -680,6 +704,25 @@ impl Config {
|
||||
}
|
||||
PathBuf::from("./kebab-state")
|
||||
}
|
||||
|
||||
/// macOS legacy config path: `~/Library/Application Support/kebab/config.toml`.
|
||||
/// Returns `None` on non-macOS or when home dir is unavailable.
|
||||
/// Used for one-time migration to the XDG-standard location.
|
||||
fn macos_legacy_config_path() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::home_dir().map(|h| {
|
||||
h.join("Library")
|
||||
.join("Application Support")
|
||||
.join("kebab")
|
||||
.join("config.toml")
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a permissive boolean — `1` / `true` / `yes` (case-insensitive)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! MCP (Model Context Protocol) server over stdio. Exposes 4 read-only
|
||||
//! tools (`search` / `ask` / `schema` / `doctor`) backed by `kebab-app`
|
||||
//! facade methods. Used by `kebab-cli`'s `Cmd::Mcp` arm.
|
||||
//! MCP (Model Context Protocol) server over stdio. Exposes 6 tools
|
||||
//! (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`)
|
||||
//! backed by `kebab-app` facade methods. Used by `kebab-cli`'s `Cmd::Mcp` arm.
|
||||
//!
|
||||
//! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
|
||||
|
||||
@@ -51,6 +51,16 @@ pub fn build_tools_vec() -> Vec<Tool> {
|
||||
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
|
||||
schema_for_type::<tools::ask::AskInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"ingest_file",
|
||||
"Ingest a single file (path) into the knowledge base. Workspace external paths allowed — bytes are copied into _external/.",
|
||||
schema_for_type::<tools::ingest_file::IngestFileInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"ingest_stdin",
|
||||
"Ingest markdown content into the knowledge base. v1 markdown only. Frontmatter (title + source_uri) auto-injected.",
|
||||
schema_for_type::<tools::ingest_stdin::IngestStdinInput>(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -133,6 +143,20 @@ impl ServerHandler for KebabHandler {
|
||||
})
|
||||
.await
|
||||
}
|
||||
"ingest_file" => {
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
self.spawn_tool(args, |state, input| {
|
||||
tools::ingest_file::handle(&state, input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"ingest_stdin" => {
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
self.spawn_tool(args, |state, input| {
|
||||
tools::ingest_stdin::handle(&state, input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
_other => Err(ErrorData::method_not_found::<
|
||||
rmcp::model::CallToolRequestMethod,
|
||||
>()),
|
||||
|
||||
39
crates/kebab-mcp/src/tools/ingest_file.rs
Normal file
39
crates/kebab-mcp/src/tools/ingest_file.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! `ingest_file` tool — wraps `kebab_app::ingest_file_with_config`.
|
||||
//! Input: { path }. Output: ingest_report.v1 JSON.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestFileInput {
|
||||
/// Absolute or relative path to the file to ingest. Workspace external
|
||||
/// paths are allowed — bytes are copied into `_external/`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: IngestFileInput) -> CallToolResult {
|
||||
let cfg_clone = (*state.config).clone();
|
||||
let path = PathBuf::from(input.path);
|
||||
match kebab_app::ingest_file_with_config(cfg_clone, &path) {
|
||||
Ok(report) => match serde_json::to_value(&report) {
|
||||
Ok(mut v) => {
|
||||
if let serde_json::Value::Object(ref mut map) = v {
|
||||
map.entry("schema_version".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String("ingest_report.v1".to_string()));
|
||||
}
|
||||
match serde_json::to_string(&v) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
},
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
44
crates/kebab-mcp/src/tools/ingest_stdin.rs
Normal file
44
crates/kebab-mcp/src/tools/ingest_stdin.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! `ingest_stdin` tool — wraps `kebab_app::ingest_stdin_with_config`.
|
||||
//! Input: { content, title, source_uri? }. Output: ingest_report.v1 JSON.
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestStdinInput {
|
||||
/// Markdown body content. v1 supports markdown only.
|
||||
pub content: String,
|
||||
/// Title for frontmatter injection.
|
||||
pub title: String,
|
||||
/// Optional source URI (e.g. https URL agent fetched from).
|
||||
pub source_uri: Option<String>,
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: IngestStdinInput) -> CallToolResult {
|
||||
let cfg_clone = (*state.config).clone();
|
||||
match kebab_app::ingest_stdin_with_config(
|
||||
cfg_clone,
|
||||
&input.content,
|
||||
&input.title,
|
||||
input.source_uri.as_deref(),
|
||||
) {
|
||||
Ok(report) => match serde_json::to_value(&report) {
|
||||
Ok(mut v) => {
|
||||
if let serde_json::Value::Object(ref mut map) = v {
|
||||
map.entry("schema_version".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String("ingest_report.v1".to_string()));
|
||||
}
|
||||
match serde_json::to_string(&v) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
},
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,5 @@ pub mod schema;
|
||||
pub mod doctor;
|
||||
pub mod search;
|
||||
pub mod ask;
|
||||
pub mod ingest_file;
|
||||
pub mod ingest_stdin;
|
||||
|
||||
117
crates/kebab-mcp/tests/tools_call_ingest_file.rs
Normal file
117
crates/kebab-mcp/tests/tools_call_ingest_file.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Integration: tools/call name=ingest_file → ingest_report.v1.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_file_tool_returns_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
fs::write(&src, "# Title\n\nbody.").unwrap();
|
||||
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let state = handler.state().clone();
|
||||
let path = src.to_string_lossy().into_owned();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_file::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_file::IngestFileInput { path },
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error.unwrap_or(false), "{result:?}");
|
||||
let text = match &result.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_file_tool_idempotent_on_second_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = kebab_config::Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
std::fs::write(&src, "# A\n\nbody.").unwrap();
|
||||
|
||||
let state = kebab_mcp::KebabAppState::new(cfg, None);
|
||||
let handler = kebab_mcp::KebabHandler::new(state);
|
||||
|
||||
// First call.
|
||||
let r1 = tokio::task::spawn_blocking({
|
||||
let state = handler.state().clone();
|
||||
let path = src.to_string_lossy().into_owned();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_file::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_file::IngestFileInput { path },
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r1.is_error.unwrap_or(false));
|
||||
let text1 = match &r1.content.first().unwrap().raw {
|
||||
rmcp::model::RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let v1: serde_json::Value = serde_json::from_str(text1).unwrap();
|
||||
assert_eq!(v1.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
|
||||
// Second call — same content, expect unchanged=1.
|
||||
let r2 = tokio::task::spawn_blocking({
|
||||
let state = handler.state().clone();
|
||||
let path = src.to_string_lossy().into_owned();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_file::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_file::IngestFileInput { path },
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r2.is_error.unwrap_or(false));
|
||||
let text2 = match &r2.content.first().unwrap().raw {
|
||||
rmcp::model::RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let v2: serde_json::Value = serde_json::from_str(text2).unwrap();
|
||||
assert_eq!(v2.get("new").and_then(|n| n.as_u64()), Some(0), "{v2:?}");
|
||||
assert_eq!(v2.get("unchanged").and_then(|n| n.as_u64()), Some(1), "{v2:?}");
|
||||
}
|
||||
89
crates/kebab-mcp/tests/tools_call_ingest_stdin.rs
Normal file
89
crates/kebab-mcp/tests/tools_call_ingest_stdin.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Integration: tools/call name=ingest_stdin → ingest_report.v1.
|
||||
//! Frontmatter precheck path also covered.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::KebabAppState;
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
fn fresh_state(dir: &std::path::Path) -> KebabAppState {
|
||||
let workspace = dir.join("notes");
|
||||
let data = dir.join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
KebabAppState::new(cfg, None)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_stdin_tool_returns_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = fresh_state(dir.path());
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let state = state.clone();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_stdin::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_stdin::IngestStdinInput {
|
||||
content: "## Body".to_string(),
|
||||
title: "X".to_string(),
|
||||
source_uri: Some("https://example.com/x".to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error.unwrap_or(false), "{result:?}");
|
||||
let text = match &result.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_stdin_tool_emits_error_v1_on_existing_frontmatter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = fresh_state(dir.path());
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let state = state.clone();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_stdin::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_stdin::IngestStdinInput {
|
||||
content: "---\ntitle: Existing\n---\n\n## Body".to_string(),
|
||||
title: "New".to_string(),
|
||||
source_uri: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.is_error, Some(true), "{result:?}");
|
||||
let text = match &result.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("error.v1")
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
//! Integration: `build_tools_vec` returns 4 tools with correct names and
|
||||
//! Integration: `build_tools_vec` returns 6 tools with correct names and
|
||||
//! inputSchema. Uses the extracted `pub fn build_tools_vec()` helper — no
|
||||
//! transport or RequestContext needed.
|
||||
|
||||
use kebab_mcp::build_tools_vec;
|
||||
|
||||
#[test]
|
||||
fn tools_list_returns_four_tools() {
|
||||
fn tools_list_returns_six_tools() {
|
||||
let tools = build_tools_vec();
|
||||
assert_eq!(tools.len(), 4, "expected exactly 4 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 6, "expected exactly 6 tools, got {}", tools.len());
|
||||
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
|
||||
assert!(names.contains(&"schema"), "missing 'schema' tool");
|
||||
assert!(names.contains(&"doctor"), "missing 'doctor' tool");
|
||||
assert!(names.contains(&"search"), "missing 'search' tool");
|
||||
assert!(names.contains(&"ask"), "missing 'ask' tool");
|
||||
assert!(names.contains(&"ingest_file"), "missing 'ingest_file' tool");
|
||||
assert!(names.contains(&"ingest_stdin"), "missing 'ingest_stdin' tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,7 +114,7 @@ max_context_tokens = 6000
|
||||
theme = "dark" # p9-fb-14 — TUI palette ("dark" / "light", default "dark")
|
||||
```
|
||||
|
||||
`KEBAB_*` 환경변수로 override 가능 (`KEBAB_MODELS_LLM_MODEL=gemma4:26b kebab …` 등). 자세한 키 목록은 `crates/kebab-config/src/lib.rs` 의 `apply_env` 매치 암.
|
||||
`KEBAB_*` 환경변수로 override 가능 (`KEBAB_MODELS_LLM_MODEL=gemma4:26b kebab …` 등). 자세한 키 목록은 `crates/kebab-config/src/lib.rs` 의 `apply_env` 매치 암. `KEBAB_READONLY=1` — write-path 비활성화 (CI 안전망). `KEBAB_PROGRESS=plain` — non-TTY 환경에서 진행 상황을 plain 한 줄씩 stderr 출력 (spinner 대신).
|
||||
|
||||
## 명령 시퀀스
|
||||
|
||||
|
||||
486
docs/mcp-usage.md
Normal file
486
docs/mcp-usage.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# MCP usage — agent integration guide
|
||||
|
||||
`kebab mcp` runs an MCP (Model Context Protocol) stdio JSON-RPC server. agent host (Claude Code / Cursor / OpenAI Agents / Copilot CLI 등) 가 본 binary 를 spawn 하여 KB 검색 / 답변 / ingest 를 호출.
|
||||
|
||||
shipped since **v0.3.1** (fb-30). 6 tool 으로 확장 (v0.3.2, fb-31).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
binary 를 PATH 에 두고 (`cargo install --path crates/kebab-cli` 또는 release tarball), agent host 의 mcp config 에 등록:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
session 시작 시 host 가 `kebab mcp` 를 spawn — process 가 session 동안 살아 있어 SQLite / Lance / fastembed 가 hot. 첫 tool call 만 cold-start 비용, 이후 sub-100ms.
|
||||
|
||||
`--config` 옵션 thread:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["--config", "/Users/me/.config/kebab/agent.toml", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Host config 예시
|
||||
|
||||
### Claude Code
|
||||
|
||||
`~/.claude/mcp.json` (또는 OS 별 동등 위치):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
session 재시작 후 `kebab` server 가 tool list 에 등장. agent 가 `mcp__kebab__search` / `mcp__kebab__ask` 등 호출 가능.
|
||||
|
||||
### Cursor
|
||||
|
||||
`~/.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cursor 의 Composer / Agent 모드에서 활성화.
|
||||
|
||||
### OpenAI Agents (`agents-sdk`)
|
||||
|
||||
Python:
|
||||
|
||||
```python
|
||||
from openai_agents import Agent, MCPServerStdio
|
||||
|
||||
kebab = MCPServerStdio(
|
||||
name="kebab",
|
||||
params={"command": "kebab", "args": ["mcp"]},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="researcher",
|
||||
mcp_servers=[kebab],
|
||||
)
|
||||
```
|
||||
|
||||
Node:
|
||||
|
||||
```ts
|
||||
import { Agent, MCPServerStdio } from "openai-agents";
|
||||
|
||||
const kebab = new MCPServerStdio({
|
||||
name: "kebab",
|
||||
params: { command: "kebab", args: ["mcp"] },
|
||||
});
|
||||
|
||||
const agent = new Agent({ name: "researcher", mcpServers: [kebab] });
|
||||
```
|
||||
|
||||
### Copilot CLI
|
||||
|
||||
`~/.config/copilot-cli/mcp.json` (or wherever the CLI looks):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 기타 host
|
||||
|
||||
stdio JSON-RPC MCP 표준을 따르는 모든 host 가 지원. 위 형식 (`command` + `args`) 만 맞추면 동작.
|
||||
|
||||
---
|
||||
|
||||
## Tool catalog (6 tools)
|
||||
|
||||
모든 tool 의 출력은 wire schema v1 JSON 을 MCP `text` content block 으로 직렬화. CLI `--json` 모드와 byte-동일 (single source of truth).
|
||||
|
||||
### `search` — corpus 검색
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "query": string, "mode"?: "lexical"\|"vector"\|"hybrid", "k"?: 1-100 }` |
|
||||
| Defaults | `mode = "hybrid"`, `k = 10` |
|
||||
| Output | `search_hit.v1` array, ranked |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "search",
|
||||
"arguments": {
|
||||
"query": "Kubernetes ingress controller setup",
|
||||
"mode": "hybrid",
|
||||
"k": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답 (한 hit 발췌):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"schema_version": "search_hit.v1",
|
||||
"rank": 1,
|
||||
"score": 0.847,
|
||||
"doc_id": "...",
|
||||
"chunk_id": "...",
|
||||
"doc_path": "k8s/ingress.md",
|
||||
"heading_path": ["Setup", "Ingress controller"],
|
||||
"snippet": "...",
|
||||
"citation": { ... }
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**언제 사용**: 사용자가 \"문서 어디 있는지\" 묻거나, agent 가 답변 전 raw chunk 가 필요할 때.
|
||||
|
||||
### `ask` — RAG 답변
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "query": string, "session_id"?: string, "mode"?: "lexical"\|"vector"\|"hybrid" }` |
|
||||
| Defaults | `mode = "hybrid"` |
|
||||
| Output | `answer.v1` (single object) |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ask",
|
||||
"arguments": {
|
||||
"query": "What's our internal Kubernetes ingress setup?",
|
||||
"session_id": "ops-onboarding-2026-05"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "answer.v1",
|
||||
"answer": "...",
|
||||
"citations": [ ... ],
|
||||
"grounded": true,
|
||||
"refusal_reason": null,
|
||||
"model": { ... },
|
||||
"conversation_id": "...",
|
||||
"turn_index": 0
|
||||
}
|
||||
```
|
||||
|
||||
**`grounded: false` 처리**: KB 에 충분한 context 없음. `refusal_reason` 확인 후 사용자에게 \"KB 에 정보 없음\" 으로 안내, 본인 지식 fallback 또는 source 요청. **paraphrase 하면 안 됨** (hallucination 위험).
|
||||
|
||||
multi-turn 은 [Session 관리](#session-관리-multi-turn-ask) 참조.
|
||||
|
||||
### `schema` — capability discovery
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{}` (no args) |
|
||||
| Output | `schema.v1` |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{ "name": "schema", "arguments": {} }
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "schema.v1",
|
||||
"kebab_version": "0.3.2",
|
||||
"wire": { "schemas": ["answer.v1", "search_hit.v1", ...] },
|
||||
"capabilities": {
|
||||
"json_mode": true,
|
||||
"rag_multi_turn": true,
|
||||
"mcp_server": true,
|
||||
"streaming_ask": false,
|
||||
...
|
||||
},
|
||||
"models": { "parser_version": "...", "embedding_version": "...", ... },
|
||||
"stats": { "doc_count": 128, "chunk_count": 2147, "asset_count": 130, ... }
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: session 시작 시 한 번 — feature gate 결정 (`capabilities.streaming_ask` true 면 streaming 사용 등). cheap call (no LLM, no embedder), session 동안 1 회 충분.
|
||||
|
||||
### `doctor` — health check
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{}` (no args) |
|
||||
| Output | `doctor.v1` |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{ "name": "doctor", "arguments": {} }
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "doctor.v1",
|
||||
"ok": true,
|
||||
"checks": [
|
||||
{ "name": "config_loaded", "ok": true, "detail": "..." },
|
||||
{ "name": "ollama_reachable", "ok": true, "detail": "..." },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: 다른 tool 이 실패하거나 비정상 응답 줄 때 first triage. `ok: false` 면 `checks[]` 의 failed entry 가 원인 — 사용자에게 보고 후 stop (자동 retry 금지).
|
||||
|
||||
### `ingest_file` — 단일 파일 저장 (mutation)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "path": string }` |
|
||||
| Supported ext | `.md` / `.pdf` / `.png` / `.jpg` / `.jpeg` (`unsupported extension` error 그 외) |
|
||||
| Output | `ingest_report.v1` (single asset) |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ingest_file",
|
||||
"arguments": { "path": "/Users/me/Downloads/article.md" }
|
||||
}
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "ingest_report.v1",
|
||||
"scanned": 1,
|
||||
"new": 1,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: 사용자가 disk 의 file 을 KB 에 저장 의향 명시 시. workspace 외부 path OK — 파일은 `<workspace.root>/_external/<hash12>.<ext>` 으로 copy. 동일 content 재 ingest 면 idempotent (`unchanged: 1`).
|
||||
|
||||
**주의**: mutation tool — 사용자 명시 의도 없을 때 자동 호출 금지.
|
||||
|
||||
### `ingest_stdin` — stdin markdown 저장 (mutation)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "content": string, "title": string, "source_uri"?: string }` |
|
||||
| v1 scope | markdown only |
|
||||
| Output | `ingest_report.v1` (single asset) |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ingest_stdin",
|
||||
"arguments": {
|
||||
"content": "## Article body\n\nMain text here.",
|
||||
"title": "Article X",
|
||||
"source_uri": "https://example.com/x"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "ingest_report.v1",
|
||||
"scanned": 1,
|
||||
"new": 1,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: agent 가 web fetch 한 markdown article 을 KB 에 저장. 사용자가 \"이거 나중에 또 보고 싶어\" 명시 시 또는 multi-turn 대화에서 자료 누적. content 가 이미 frontmatter (`---` 시작) 이면 error — `ingest_file` 사용.
|
||||
|
||||
`title` + `source_uri` 가 frontmatter 로 자동 prepend → `Document.metadata` 에 저장 → 후속 `search` 결과의 `doc_meta` 에 포함. agent 가 source URL 추적 가능.
|
||||
|
||||
**주의**: mutation tool. 같은 content 무한 ingest 안 함 (idempotent 보장이지만 embedding cost 낭비).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `isError: true` + `error.v1` content
|
||||
|
||||
tool dispatch 가 `Err` 반환 시. content 의 `error.v1` JSON 의 `code` 로 분기:
|
||||
|
||||
| code | 의미 | 조치 |
|
||||
|------|------|------|
|
||||
| `config_invalid` | `--config` path missing / TOML parse 실패 | path 확인 + `kebab schema` 로 검증. `details.path` + `details.cause` 확인. |
|
||||
| `not_indexed` | `kebab.sqlite` 미존재 / migration 미실행 | 사용자에게 `kebab init` + `kebab ingest` 실행 안내. retry 자동 금지. |
|
||||
| `model_unreachable` | Ollama endpoint 연결 실패 | Ollama 실행 확인 (`ollama serve`). `details.endpoint` 의 host 가 reachable 한지. retry 1-2 회 후 사용자 보고. |
|
||||
| `model_not_pulled` | Ollama model not found | 사용자에게 `ollama pull <model>` 안내 — `details.model` 표시. |
|
||||
| `timeout` | LLM stream / embed deadline 초과 | 일시적이면 retry 1 회. 재발 시 사용자 보고 (model 응답 느림 / Ollama load). |
|
||||
| `io_error` | filesystem / 권한 / disk full | `details.kind` 보고 사용자에게 disk space / permission 확인 안내. |
|
||||
| `generic` | catch-all | `details.chain` (verbose 시) 보고 사용자에게 그대로 전달. retry 금지. |
|
||||
|
||||
`hint` field 가 있으면 사용자에게 그대로 보여주기 (각 code 의 가장 빠른 조치).
|
||||
|
||||
### `grounded: false` (ask refusal)
|
||||
|
||||
`isError: false` (정상 응답). KB 에 충분한 context 없음. `refusal_reason` 확인 후:
|
||||
|
||||
- `NoChunks` — 검색 자체가 0 hit. 다른 표현 / 더 일반적인 query 시도.
|
||||
- `LowScores` — hit 있지만 score gate 미달. `kebab search` (별도) 로 raw hit 확인.
|
||||
- 그 외 — refusal 메시지 그대로 사용자에게 보고.
|
||||
|
||||
자동 paraphrase 금지. 사용자에게 \"KB 에 정보 없음\" 명시 후 본인 지식 또는 source 요청.
|
||||
|
||||
### `doctor` `ok: false`
|
||||
|
||||
다른 tool 호출 전 `doctor` 부터. `checks[]` 의 failed entry 원인 명시 — 사용자에게 보고 후 stop.
|
||||
|
||||
### empty `search` result
|
||||
|
||||
`isError: false`, content = `[]` (빈 array). KB 에 매칭 없음. `mode` 변경 (lexical → vector or vice versa) 또는 query 표현 다양화. 그래도 빈 결과면 KB coverage 부족 — 사용자에게 보고.
|
||||
|
||||
### tool not found
|
||||
|
||||
`tools/list` 에서 본 binary 의 6 tool 확인. 0.3.1 (fb-30) 은 4 tool, 0.3.2 (fb-31) 부터 6. binary version 확인:
|
||||
|
||||
```json
|
||||
{ "name": "schema", "arguments": {} }
|
||||
```
|
||||
|
||||
응답의 `kebab_version` 이 0.3.2+ 인지 확인.
|
||||
|
||||
---
|
||||
|
||||
## Session 관리 (multi-turn ask)
|
||||
|
||||
`ask` tool 의 `session_id` 가 multi-turn RAG context 활성화. 같은 `session_id` 로 연속 호출 시 이전 Q/A history 가 새 query 의 retrieval expansion + prompt context 에 포함.
|
||||
|
||||
### session_id 명명
|
||||
|
||||
`<topic>-<date>` 형식 권장 — 사용자 친화 + uniqueness:
|
||||
|
||||
- `ops-onboarding-2026-05`
|
||||
- `kubernetes-ingress-debug-2026-05-07`
|
||||
- `agent-research-session-1` (auto-numbered)
|
||||
|
||||
session_id 는 임의 string — kebab 이 처음 보는 id 면 새 session 생성, 기존 id 면 history append.
|
||||
|
||||
### 언제 새 session 시작?
|
||||
|
||||
- 주제 완전 전환 (KB 의 다른 도메인) — 이전 history 가 noise.
|
||||
- 사용자 명시 reset 요청.
|
||||
- Long session (50+ turn) 의 context bloat — 새 session 으로 fresh start.
|
||||
|
||||
### Session lifetime
|
||||
|
||||
session 데이터는 SQLite `chat_sessions` + `chat_turns` 에 영속. `kebab reset --data-only` 가 모두 wipe. session 별 삭제 명령은 없음 (P+).
|
||||
|
||||
### 예시 multi-turn flow
|
||||
|
||||
```json
|
||||
// turn 1
|
||||
{ "name": "ask", "arguments": {
|
||||
"query": "What's our internal Kubernetes ingress setup?",
|
||||
"session_id": "ops-2026-05"
|
||||
}}
|
||||
// → answer.v1 with conversation_id, turn_index: 0
|
||||
|
||||
// turn 2 — 이전 답변을 context 로 retrieval expansion
|
||||
{ "name": "ask", "arguments": {
|
||||
"query": "What about TLS?",
|
||||
"session_id": "ops-2026-05"
|
||||
}}
|
||||
// → kebab 가 "TLS" 만으로 retrieval 안 함, 이전 \"Kubernetes ingress\" history 포함 query 로 검색
|
||||
|
||||
// turn 3 — 명시적 reference
|
||||
{ "name": "ask", "arguments": {
|
||||
"query": "How does that compare to AWS ALB?",
|
||||
"session_id": "ops-2026-05"
|
||||
}}
|
||||
```
|
||||
|
||||
### Session vs single-shot
|
||||
|
||||
`session_id` 없이 `ask` 호출 = single-shot. agent host 자체가 conversation 추적하면 single-shot + agent-side context 도 OK. session 이 필요한 경우:
|
||||
|
||||
- KB 가 \"이전 질문\" 을 retrieval expansion 에 사용해야 정확 (e.g. follow-up 의 대명사).
|
||||
- 한 session 안에서 같은 chunk 반복 fetch 회피 (kebab 가 turn 간 chunk overlap 인지).
|
||||
|
||||
agent host 가 conversation 추적 + 충분한 context 보유면 session 불필요.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **첫 tool call**: cold start ~1-2s (SQLite open + Lance dataset open + fastembed model load).
|
||||
- **이후 tool call (same session)**: hot — search ~50-200ms, ask ~수 초 (Ollama LLM dominant).
|
||||
- **session 종료** (host 가 process kill): 모든 cache lost. 다음 session 첫 call 다시 cold.
|
||||
- **`schema` / `doctor`**: cheap (no LLM / no embedder), 매 call ~ms.
|
||||
- **`ingest_file` / `ingest_stdin`**: 첫 call 시 fastembed cold start. 이후 file 당 ~수 백 ms (parse + chunk + embed).
|
||||
|
||||
cold-start 회피하려면 host 가 long-running session 유지 (Claude Code default).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- stdio MCP — 외부 네트워크 노출 없음. agent host 만 access.
|
||||
- `kebab mcp` 가 호출하는 facade 는 `--config` 의 권한으로 동작. config 내 secret (Ollama API key 등) 은 process 환경에 한정.
|
||||
- mutation tool (`ingest_file` / `ingest_stdin`) 는 사용자 명시 의도 없이 자동 호출 금지 — agent 측 가드.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- CLI usage: `kebab --help` + [README.md](../README.md)
|
||||
- Wire schemas: `docs/wire-schema/v1/*.schema.json`
|
||||
- design contract: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §10.2
|
||||
- Claude Code 전용 skill: `integrations/claude-code/kebab/SKILL.md`
|
||||
- HOTFIXES (post-merge deviations): `tasks/HOTFIXES.md`
|
||||
796
docs/superpowers/plans/2026-05-07-fb-26-fb-28-agent-ux.md
Normal file
796
docs/superpowers/plans/2026-05-07-fb-26-fb-28-agent-ux.md
Normal file
@@ -0,0 +1,796 @@
|
||||
# Agent UX Improvements: Ingest Log Consistency + Invocation Flags Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix ingest progress log inconsistency (fb-26), add `--readonly`/`--quiet` global CLI flags (fb-28), and sync CLAUDE.md wire schema list.
|
||||
|
||||
**Architecture:** Three independent changes bundled in one branch. `progress.rs` gets a `quiet` field on `ProgressMode::Human` and two bug fixes in `handle_human`. `main.rs` gets two new global flags on `Cli` plus a readonly guard block before subcommand dispatch. CLAUDE.md gets a corrected schema list.
|
||||
|
||||
**Tech Stack:** Rust 2024, clap (already in use), indicatif (already in use), tempfile (already in use for tests)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `CLAUDE.md` | schema list: remove 3 phantom, add 3 missing |
|
||||
| `crates/kebab-cli/src/progress.rs` | `ProgressMode::Human { quiet }` + `from_flags` signature + `handle_human` bug fixes |
|
||||
| `crates/kebab-cli/src/main.rs` | `Cli` `--readonly`/`--quiet` flags + `is_mutating()` + readonly guard + update `from_flags` call |
|
||||
| `crates/kebab-cli/tests/ingest_progress_cli.rs` | Add `KEBAB_PROGRESS=plain` test + `--quiet` suppression test |
|
||||
| `crates/kebab-cli/tests/cli_readonly_quiet.rs` | New: readonly/quiet integration tests |
|
||||
| `tasks/HOTFIXES.md` | `readonly_mode` error code entry |
|
||||
| `tasks/p9/p9-fb-26-ingest-log-consistency.md` | `status: open` → `status: merged` |
|
||||
| `tasks/p9/p9-fb-28-agent-invocation-flags.md` | `status: open` → `status: merged` |
|
||||
| `tasks/INDEX.md` | mark fb-26 + fb-28 done |
|
||||
| `HANDOFF.md` | one-line entry |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: CLAUDE.md wire schema list sync
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md:63`
|
||||
|
||||
- [ ] **Step 1: Edit CLAUDE.md**
|
||||
|
||||
Find the line (currently line 63):
|
||||
|
||||
```
|
||||
All `--json` output carries a `schema_version` field. Current schemas: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `eval_run.v1`, `eval_compare.v1`, `list_docs.v1`, `schema.v1`, `error.v1`.
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```
|
||||
All `--json` output carries a `schema_version` field. Current schemas: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`, `error.v1`, `chunk_inspection.v1`, `citation.v1`, `doc_summary.v1`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
ls docs/wire-schema/v1/ | sed 's/\.schema\.json$/\.v1/' | sort
|
||||
```
|
||||
|
||||
Confirm output matches the 11 schemas listed (answer, chunk_inspection, citation, doc_summary, doctor, error, ingest_progress, ingest_report, reset_report, schema, search_hit).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: sync wire schema list in CLAUDE.md (remove phantom eval_run/eval_compare/list_docs, add chunk_inspection/citation/doc_summary)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: progress.rs — ProgressMode quiet field + from_flags update
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/progress.rs`
|
||||
|
||||
- [ ] **Step 1: Update the existing unit tests to expect new from_flags signature**
|
||||
|
||||
In `crates/kebab-cli/src/progress.rs`, the `#[cfg(test)]` block has two tests that call `from_flags`. Update them:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn from_flags_json_takes_priority_over_tty() {
|
||||
assert_eq!(ProgressMode::from_flags(true, false, false), ProgressMode::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_flags_human_reflects_stderr_tty() {
|
||||
match ProgressMode::from_flags(false, false, false) {
|
||||
ProgressMode::Human { .. } => {}
|
||||
other => panic!("expected Human mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also add a new test for quiet and plain_env:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn from_flags_quiet_sets_quiet_field() {
|
||||
match ProgressMode::from_flags(false, true, false) {
|
||||
ProgressMode::Human { quiet: true, .. } => {}
|
||||
other => panic!("expected Human{{quiet:true}}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_flags_plain_env_forces_tty_false() {
|
||||
// plain_env=true must set tty=false regardless of terminal state.
|
||||
match ProgressMode::from_flags(false, false, true) {
|
||||
ProgressMode::Human { tty: false, .. } => {}
|
||||
other => panic!("expected Human{{tty:false}}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail (compilation error)**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli --lib 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: compile error — `from_flags` called with 1 argument but expects more.
|
||||
|
||||
- [ ] **Step 3: Implement ProgressMode changes**
|
||||
|
||||
In `crates/kebab-cli/src/progress.rs`, replace the enum and impl:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProgressMode {
|
||||
/// stdout = line-delimited `ingest_progress.v1`. stderr stays
|
||||
/// silent for events (errors / log frames still go to stderr).
|
||||
Json,
|
||||
/// stdout reserved for the final report; stderr gets an indicatif
|
||||
/// `ProgressBar` (TTY) or one short line per event (non-TTY).
|
||||
Human { tty: bool, quiet: bool },
|
||||
}
|
||||
|
||||
impl ProgressMode {
|
||||
/// Pick the right mode from caller flags.
|
||||
///
|
||||
/// - `json`: `--json` flag — takes priority, returns `Json`.
|
||||
/// - `quiet`: `--quiet` flag — suppresses human-readable stderr when `Human`.
|
||||
/// - `plain_env`: `KEBAB_PROGRESS=plain` — forces `tty=false` even in a TTY,
|
||||
/// for CI environments that emulate a TTY with a pty wrapper.
|
||||
pub fn from_flags(json: bool, quiet: bool, plain_env: bool) -> Self {
|
||||
if json {
|
||||
Self::Json
|
||||
} else {
|
||||
let tty = !plain_env && std::io::stderr().is_terminal();
|
||||
Self::Human { tty, quiet }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also update `handle()` in `impl ProgressDisplay` to pass `quiet` through, and update `handle_human`'s signature to accept `quiet` (body unchanged yet — Task 3 implements the suppression):
|
||||
|
||||
```rust
|
||||
fn handle(&mut self, event: &IngestEvent) -> anyhow::Result<()> {
|
||||
match self.mode {
|
||||
ProgressMode::Json => emit_json(event),
|
||||
ProgressMode::Human { tty, quiet } => self.handle_human(event, tty, quiet),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And update the `handle_human` signature (keep body as-is, just add the param):
|
||||
|
||||
```rust
|
||||
fn handle_human(&mut self, event: &IngestEvent, tty: bool, quiet: bool) -> anyhow::Result<()> {
|
||||
let _ = quiet; // used in Task 3; suppress unused warning for now
|
||||
// ... rest of existing body unchanged ...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli --lib 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: all 5 unit tests in progress.rs pass.
|
||||
|
||||
- [ ] **Step 5: Fix the compile error in main.rs (from_flags call)**
|
||||
|
||||
At line ~373 in `crates/kebab-cli/src/main.rs`, find:
|
||||
|
||||
```rust
|
||||
let mode = progress::ProgressMode::from_flags(cli.json);
|
||||
```
|
||||
|
||||
Replace with a temporary stub that compiles (full implementation in Task 4):
|
||||
|
||||
```rust
|
||||
let mode = progress::ProgressMode::from_flags(cli.json, false, false);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify workspace compiles**
|
||||
|
||||
```bash
|
||||
cargo build -p kebab-cli 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `Finished dev` with no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/src/progress.rs crates/kebab-cli/src/main.rs
|
||||
git commit -m "feat(fb-26): extend ProgressMode with quiet field, update from_flags signature"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: progress.rs — handle_human bug fixes + quiet suppression
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/progress.rs`
|
||||
|
||||
The current `handle_human` signature is `fn handle_human(&mut self, event: &IngestEvent, tty: bool)`. It has two bugs:
|
||||
1. `Aborted` — `writeln!` fires unconditionally (even in TTY mode)
|
||||
2. `Completed` TTY path — no final summary line after `bar.finish_and_clear()`
|
||||
|
||||
- [ ] **Step 1: Replace handle_human entirely**
|
||||
|
||||
Replace the full `handle_human` method (lines 99–191 in progress.rs) with:
|
||||
|
||||
```rust
|
||||
fn handle_human(&mut self, event: &IngestEvent, tty: bool, quiet: bool) -> anyhow::Result<()> {
|
||||
match event {
|
||||
IngestEvent::ScanStarted { root } => {
|
||||
let bar = ProgressBar::new_spinner().with_message(format!("scanning {root}"));
|
||||
bar.set_draw_target(if tty && !quiet {
|
||||
ProgressDrawTarget::stderr()
|
||||
} else {
|
||||
ProgressDrawTarget::hidden()
|
||||
});
|
||||
if tty && !quiet {
|
||||
bar.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.bar = Some(bar);
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: scanning {root}…");
|
||||
}
|
||||
}
|
||||
IngestEvent::ScanCompleted { total } => {
|
||||
if let Some(bar) = self.bar.as_mut() {
|
||||
bar.disable_steady_tick();
|
||||
bar.set_length(u64::from(*total));
|
||||
bar.set_position(0);
|
||||
bar.set_style(
|
||||
ProgressStyle::with_template(
|
||||
"ingest [{bar:30}] {pos}/{len} {wide_msg}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
bar.set_message("");
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: scan complete ({total} assets)");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetStarted {
|
||||
idx,
|
||||
total,
|
||||
path,
|
||||
media,
|
||||
} => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message(format!("{media} {path}"));
|
||||
}
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} {media} {path}");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetFinished { idx, .. } => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_position(u64::from(*idx));
|
||||
}
|
||||
}
|
||||
IngestEvent::Completed { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
// Always emit the summary in both TTY and non-TTY (unless quiet).
|
||||
// Bug fix: previously TTY had no summary line after bar.finish_and_clear().
|
||||
if !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned,
|
||||
counts.new,
|
||||
counts.updated,
|
||||
counts.skipped,
|
||||
counts.errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
IngestEvent::Aborted { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.abandon_with_message(format!(
|
||||
"aborted at {}/{}",
|
||||
counts.scanned.saturating_sub(counts.errors),
|
||||
counts.scanned
|
||||
));
|
||||
}
|
||||
// Bug fix: was unconditional (fired in TTY too).
|
||||
// In TTY, bar.abandon_with_message already prints the final state.
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"ingest: aborted (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned,
|
||||
counts.new,
|
||||
counts.updated,
|
||||
counts.skipped,
|
||||
counts.errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run unit tests**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli --lib 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 3: Run integration tests that cover progress**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli --test ingest_progress_cli 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: all pass (non-TTY tests verify stderr still contains `ingest:` lines).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/src/progress.rs
|
||||
git commit -m "fix(fb-26): Completed TTY missing summary + Aborted unconditional writeln + quiet suppression in handle_human"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: main.rs — --readonly/--quiet flags + is_mutating + readonly guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Add `readonly` and `quiet` to `Cli` struct**
|
||||
|
||||
In `crates/kebab-cli/src/main.rs`, find the `struct Cli` definition (around line 16). Add two fields after the `json` field:
|
||||
|
||||
```rust
|
||||
/// Disable all write-path subcommands (also: KEBAB_READONLY=1 env var).
|
||||
#[arg(long, global = true, env = "KEBAB_READONLY")]
|
||||
readonly: bool,
|
||||
|
||||
/// Suppress all human-readable stderr output: progress lines, hints.
|
||||
/// Implied by `--json`.
|
||||
#[arg(long, global = true)]
|
||||
quiet: bool,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `is_mutating` function**
|
||||
|
||||
Add this free function near the bottom of `main.rs`, before `confirm_destructive`:
|
||||
|
||||
```rust
|
||||
/// Returns `true` for subcommands that write to the KB. Used by the
|
||||
/// `--readonly` guard to reject mutating invocations.
|
||||
fn is_mutating(cmd: &Cmd) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
Cmd::Ingest { .. } | Cmd::IngestFile { .. } | Cmd::IngestStdin { .. } | Cmd::Reset { .. }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add readonly guard in main()**
|
||||
|
||||
In `main()`, after the logging init block (after line ~299) and before the `match run(&cli)` call, insert:
|
||||
|
||||
```rust
|
||||
if cli.readonly && is_mutating(&cli.command) {
|
||||
let msg = "kebab: readonly mode — mutating commands are disabled";
|
||||
if cli.json {
|
||||
let v1 = kebab_app::ErrorV1 {
|
||||
schema_version: kebab_app::ERROR_V1_ID.to_string(),
|
||||
code: "readonly_mode".to_string(),
|
||||
message: msg.to_string(),
|
||||
details: serde_json::json!({}),
|
||||
hint: Some(
|
||||
"remove --readonly (or unset KEBAB_READONLY) to allow writes".to_string(),
|
||||
),
|
||||
};
|
||||
let v = wire::wire_error_v1(&v1);
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(&v).unwrap_or_else(|_| msg.to_string())
|
||||
);
|
||||
} else {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update from_flags call to pass quiet and KEBAB_PROGRESS env**
|
||||
|
||||
Find the temporary stub from Task 2 Step 5 (inside `Cmd::Ingest` arm, line ~373):
|
||||
|
||||
```rust
|
||||
let mode = progress::ProgressMode::from_flags(cli.json, false, false);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
let plain_env = std::env::var("KEBAB_PROGRESS")
|
||||
.map(|v| v.eq_ignore_ascii_case("plain"))
|
||||
.unwrap_or(false);
|
||||
let mode = progress::ProgressMode::from_flags(cli.json, cli.quiet, plain_env);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to verify no compile errors**
|
||||
|
||||
```bash
|
||||
cargo build -p kebab-cli 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `Finished dev` with no errors.
|
||||
|
||||
- [ ] **Step 6: Quick smoke — readonly blocks ingest**
|
||||
|
||||
```bash
|
||||
# Build debug binary first if needed
|
||||
cargo build -p kebab-cli
|
||||
|
||||
# Test: readonly should block ingest
|
||||
./target/debug/kebab --readonly ingest --root /tmp 2>&1; echo "exit: $?"
|
||||
```
|
||||
|
||||
Expected: stderr shows `kebab: readonly mode — mutating commands are disabled`, exit code 1.
|
||||
|
||||
```bash
|
||||
# Test: readonly allows search (no KB needed — just check it doesn't block early)
|
||||
./target/debug/kebab --readonly search "test" 2>&1 | head -3
|
||||
```
|
||||
|
||||
Expected: error about not being initialized or similar — NOT a readonly error.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/src/main.rs
|
||||
git commit -m "feat(fb-28): --readonly/--quiet global flags + KEBAB_READONLY env + is_mutating guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Integration tests
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/kebab-cli/tests/cli_readonly_quiet.rs`
|
||||
- Modify: `crates/kebab-cli/tests/ingest_progress_cli.rs`
|
||||
|
||||
- [ ] **Step 1: Create cli_readonly_quiet.rs**
|
||||
|
||||
Create `crates/kebab-cli/tests/cli_readonly_quiet.rs`:
|
||||
|
||||
```rust
|
||||
//! Integration tests for `--readonly` and `--quiet` global flags (fb-28).
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
|
||||
fn kebab_bin() -> std::path::PathBuf {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
std::path::PathBuf::from(manifest)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target/debug/kebab")
|
||||
}
|
||||
|
||||
fn fixture_workspace() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&ws).unwrap();
|
||||
let mut a = std::fs::File::create(ws.join("a.md")).unwrap();
|
||||
writeln!(a, "# Alpha\n\nfirst doc").unwrap();
|
||||
(tmp, ws)
|
||||
}
|
||||
|
||||
fn xdg_envs(tmp_path: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] {
|
||||
[
|
||||
("XDG_CONFIG_HOME", tmp_path.join("cfg")),
|
||||
("XDG_DATA_HOME", tmp_path.join("data")),
|
||||
("XDG_CACHE_HOME", tmp_path.join("cache")),
|
||||
("XDG_STATE_HOME", tmp_path.join("state")),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_ingest() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("readonly mode"),
|
||||
"expected 'readonly mode' in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_ingest_file() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let file = ws.join("a.md");
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "ingest-file", file.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_flag_blocks_reset() {
|
||||
let (tmp, _ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "reset", "--data-only", "--yes"])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kebab_readonly_env_blocks_ingest() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["ingest", "--root", ws.to_str().unwrap()])
|
||||
.env("KEBAB_READONLY", "1")
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("readonly mode"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_json_mode_emits_error_v1() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--readonly", "--json", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.status.code(), Some(1), "expected exit 1");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let v: serde_json::Value = serde_json::from_str(stderr.trim())
|
||||
.unwrap_or_else(|e| panic!("expected error.v1 JSON on stderr, got {stderr:?}: {e}"));
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("error.v1"),
|
||||
"expected schema_version=error.v1"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("code").and_then(|s| s.as_str()),
|
||||
Some("readonly_mode"),
|
||||
"expected code=readonly_mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_flag_suppresses_progress_stderr() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--quiet", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"exit: {:?}, stderr: {}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"expected empty stderr with --quiet, got: {stderr}"
|
||||
);
|
||||
// stdout should still have the human summary
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("scanned"),
|
||||
"expected report summary on stdout, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_with_json_stdout_has_report_stderr_is_empty() {
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["--quiet", "--json", "ingest", "--root", ws.to_str().unwrap()])
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.is_empty(), "expected empty stderr, got: {stderr}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let last_line = stdout.lines().last().unwrap_or("");
|
||||
let v: serde_json::Value = serde_json::from_str(last_line)
|
||||
.unwrap_or_else(|e| panic!("expected JSON on stdout last line, got {last_line:?}: {e}"));
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add KEBAB_PROGRESS=plain test to ingest_progress_cli.rs**
|
||||
|
||||
Append this test to `crates/kebab-cli/tests/ingest_progress_cli.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn kebab_progress_plain_env_emits_append_lines() {
|
||||
// KEBAB_PROGRESS=plain forces non-TTY branch even in TTY-emulated envs.
|
||||
// In subprocess tests there's no TTY anyway, so this primarily verifies
|
||||
// the env var is accepted and the non-TTY path still works.
|
||||
let (tmp, ws) = fixture_workspace();
|
||||
let out = Command::new(kebab_bin())
|
||||
.args(["ingest", "--root", ws.to_str().unwrap()])
|
||||
.env("KEBAB_PROGRESS", "plain")
|
||||
.envs(xdg_envs(tmp.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("ingest: scanning"),
|
||||
"expected 'ingest: scanning' in stderr, got: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("ingest: complete"),
|
||||
"expected 'ingest: complete' in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the binary**
|
||||
|
||||
```bash
|
||||
cargo build -p kebab-cli 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run new tests**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli --test cli_readonly_quiet 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: all 7 tests pass.
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli --test ingest_progress_cli kebab_progress_plain_env 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 1 test passes.
|
||||
|
||||
- [ ] **Step 5: Run full kebab-cli test suite**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/tests/cli_readonly_quiet.rs crates/kebab-cli/tests/ingest_progress_cli.rs
|
||||
git commit -m "test(fb-26,fb-28): integration tests for readonly/quiet flags and KEBAB_PROGRESS=plain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Docs — HOTFIXES.md + task status + HANDOFF.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `tasks/HOTFIXES.md`
|
||||
- Modify: `tasks/p9/p9-fb-26-ingest-log-consistency.md`
|
||||
- Modify: `tasks/p9/p9-fb-28-agent-invocation-flags.md`
|
||||
- Modify: `tasks/INDEX.md`
|
||||
- Modify: `HANDOFF.md`
|
||||
|
||||
- [ ] **Step 1: Add HOTFIXES entry**
|
||||
|
||||
In `tasks/HOTFIXES.md`, find the existing entries and add a new dated entry. Append (or insert under the appropriate date):
|
||||
|
||||
```markdown
|
||||
## 2026-05-07
|
||||
|
||||
### fb-26: ingest log `Aborted` unconditional writeln + `Completed` TTY no summary
|
||||
|
||||
- **File**: `crates/kebab-cli/src/progress.rs`
|
||||
- `Aborted` handler had an unconditional `writeln!` that fired in TTY mode too, duplicating output below `bar.abandon_with_message`. Fixed: guarded with `if !tty && !quiet`.
|
||||
- `Completed` TTY path called `bar.finish_and_clear()` with no subsequent summary line. Fixed: always emit `ingest: complete (...)` writeln when `!quiet`.
|
||||
- Added `KEBAB_PROGRESS=plain` env override to force non-TTY branch in CI pty wrappers.
|
||||
|
||||
### fb-28: new error code `readonly_mode`
|
||||
|
||||
- **File**: `crates/kebab-cli/src/main.rs`
|
||||
- `error.v1` `code: "readonly_mode"` added for `--readonly` / `KEBAB_READONLY=1` guard block. Constructed directly in `main()`, not via `classify()`.
|
||||
- Blocked subcommands: `ingest`, `ingest-file`, `ingest-stdin`, `reset`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mark task specs as merged**
|
||||
|
||||
In `tasks/p9/p9-fb-26-ingest-log-consistency.md`, change:
|
||||
|
||||
```yaml
|
||||
status: open
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```yaml
|
||||
status: merged
|
||||
```
|
||||
|
||||
In `tasks/p9/p9-fb-28-agent-invocation-flags.md`, change:
|
||||
|
||||
```yaml
|
||||
status: open
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```yaml
|
||||
status: merged
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update tasks/INDEX.md**
|
||||
|
||||
Find the rows for `p9-fb-26` and `p9-fb-28` in `tasks/INDEX.md` and mark them done (⏳ → ✅ or equivalent per the existing format in the file).
|
||||
|
||||
- [ ] **Step 4: Update HANDOFF.md**
|
||||
|
||||
In `HANDOFF.md`, find the "머지 후 발견된 버그 / 결정 (요약)" section and add:
|
||||
|
||||
```
|
||||
- fb-26: ingest log Aborted unconditional writeln (TTY dupe) + Completed TTY no summary fixed; KEBAB_PROGRESS=plain added
|
||||
- fb-28: --readonly (KEBAB_READONLY) blocks Ingest/IngestFile/IngestStdin/Reset; --quiet suppresses progress stderr; error.v1 code: "readonly_mode"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit all docs**
|
||||
|
||||
```bash
|
||||
git add tasks/HOTFIXES.md tasks/p9/p9-fb-26-ingest-log-consistency.md tasks/p9/p9-fb-28-agent-invocation-flags.md tasks/INDEX.md HANDOFF.md
|
||||
git commit -m "docs: mark fb-26 + fb-28 merged, HOTFIXES entry for readonly_mode + progress bugs"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1206,6 +1206,12 @@ hint edit ~/.config/kebab/config.toml then `kebab ingest ~/KnowledgeBase`
|
||||
- 항상 POSIX path 정규화 후 DB 저장. `to_posix` 단일 함수.
|
||||
- 심볼릭 링크: 1차 follow + 무한루프 detect (`canonicalize` 후 set 추적).
|
||||
|
||||
### 6.7 `_external/` subdirectory (fb-31)
|
||||
|
||||
`<workspace.root>/_external/` 가 single-file / stdin ingest 의 destination. 명명: `<blake3-12>.<ext>` (12-char hex prefix of content hash + 원래 extension). deterministic — 동일 content 재 ingest 면 idempotent.
|
||||
|
||||
첫 생성 시 `<workspace.root>/.kebabignore` 에 `_external/` line 자동 append — 향후 `kebab ingest` 전체 walk 가 이 디렉토리 재 walk 안 함 (re-ingestion 무한 루프 방지).
|
||||
|
||||
---
|
||||
|
||||
## 7. Trait contracts (kebab-core)
|
||||
|
||||
149
docs/superpowers/specs/2026-05-07-fb-26-fb-28-agent-ux-design.md
Normal file
149
docs/superpowers/specs/2026-05-07-fb-26-fb-28-agent-ux-design.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
date: 2026-05-07
|
||||
tasks: [p9-fb-26, p9-fb-28, claude-md-schema-sync]
|
||||
title: "Agent UX improvements: ingest log consistency + invocation flags + schema list sync"
|
||||
status: approved
|
||||
target_version: 0.3.3
|
||||
branch: feat/p9-fb-26-fb-28-agent-ux
|
||||
---
|
||||
|
||||
# Agent UX improvements
|
||||
|
||||
Three bundled changes shipped as one PR: CLAUDE.md wire schema list sync (doc-only), ingest log consistency fix (fb-26), and agent invocation flags (fb-28).
|
||||
|
||||
---
|
||||
|
||||
## §1 — CLAUDE.md wire schema list sync
|
||||
|
||||
### Problem
|
||||
|
||||
`CLAUDE.md` §Wire schema v1 lists schemas that do not exist on disk, and omits schemas that do.
|
||||
|
||||
| Schema | CLAUDE.md | `docs/wire-schema/v1/` |
|
||||
|---|---|---|
|
||||
| `eval_run.v1` | listed | **missing** |
|
||||
| `eval_compare.v1` | listed | **missing** |
|
||||
| `list_docs.v1` | listed | **missing** |
|
||||
| `chunk_inspection.v1` | **absent** | present |
|
||||
| `citation.v1` | **absent** | present |
|
||||
| `doc_summary.v1` | **absent** | present |
|
||||
|
||||
### Fix
|
||||
|
||||
Update the schema list in `CLAUDE.md` to match `docs/wire-schema/v1/` exactly. No other changes.
|
||||
|
||||
**Correct list**: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`, `error.v1`, `chunk_inspection.v1`, `citation.v1`, `doc_summary.v1`
|
||||
|
||||
---
|
||||
|
||||
## §2 — fb-26: Ingest log consistency
|
||||
|
||||
### Problem
|
||||
|
||||
`crates/kebab-cli/src/progress.rs` has two bugs that break the TTY/non-TTY symmetry:
|
||||
|
||||
1. **`Aborted` handler** (`L170-188`): `writeln!` is unconditional — fires in TTY mode too, printing a duplicate summary below the spinner's abandoned message.
|
||||
2. **`Completed` TTY path** (`L153-169`): `bar.finish_and_clear()` clears the bar with no subsequent summary line. Users see the run end silently.
|
||||
|
||||
Additionally, there is no escape hatch for CI environments that emulate a TTY (pty wrapper), which causes unintended spinner output in CI logs.
|
||||
|
||||
### Design
|
||||
|
||||
**Behavioral contract** (Option A — already the intent, bug-fixed):
|
||||
|
||||
| Mode | Progress | Final summary |
|
||||
|---|---|---|
|
||||
| TTY | indicatif in-place spinner → progress bar | single `ingest: complete / aborted` writeln after bar clears |
|
||||
| non-TTY | append-only writeln per event | same `ingest: complete / aborted` writeln |
|
||||
| `--json` | silent stderr | `ingest_report.v1` stdout only |
|
||||
|
||||
**Changes to `handle_human`:**
|
||||
|
||||
1. `Completed` TTY: after `bar.finish_and_clear()`, add `writeln!(stderr, "ingest: complete (...)")` — same format as non-TTY branch.
|
||||
2. `Aborted` TTY: wrap the existing unconditional `writeln!` in `if !tty { ... }`. The `bar.abandon_with_message(...)` already prints the spinner's final state on TTY.
|
||||
3. Unify summary format string: `ingest: complete (scanned={} new={} updated={} skipped={} errors={})` and `ingest: aborted (...)` — identical prefix in both modes.
|
||||
|
||||
**`KEBAB_PROGRESS=plain` env override:**
|
||||
|
||||
- When set (any non-empty value), force non-TTY branch regardless of `IsTerminal`.
|
||||
- Implemented in `ProgressMode::from_flags` — check `KEBAB_PROGRESS=plain` env, set `tty=false` when present.
|
||||
- Allows CI with pty wrappers to opt-in to append-only output explicitly.
|
||||
|
||||
### Testing
|
||||
|
||||
- Snapshot test: non-TTY stream for a minimal ingest (2-file TempDir KB) captures `ScanStarted`, `ScanCompleted`, `AssetStarted × 2`, `Completed` with correct prefixes.
|
||||
- `KEBAB_PROGRESS=plain` env: TTY path still uses append-only output.
|
||||
- `KEBAB_PROGRESS=plain` + `--json`: `--json` takes precedence, no human lines.
|
||||
- Manual smoke: `kebab ingest --config /tmp/... 2>&1 | cat` shows all event lines + final summary.
|
||||
|
||||
---
|
||||
|
||||
## §3 — fb-28: Agent invocation flags
|
||||
|
||||
### Problem
|
||||
|
||||
Agents invoking `kebab` face two issues:
|
||||
|
||||
1. No way to enforce read-only KB access — a hallucinating agent could call `kebab reset` or `kebab ingest` unexpectedly.
|
||||
2. Progress/spinner output leaks to stderr even in non-TTY agent invocations where TTY is emulated, adding noise to agent context.
|
||||
|
||||
### Design
|
||||
|
||||
#### Global flags on `Cli`
|
||||
|
||||
```
|
||||
kebab [--readonly] [--quiet] <subcommand> [...]
|
||||
```
|
||||
|
||||
Both are global flags added to the `Cli` struct in `main.rs`. Evaluated before subcommand dispatch.
|
||||
|
||||
#### `--readonly` / `KEBAB_READONLY=1`
|
||||
|
||||
- Environment variable `KEBAB_READONLY=1` is equivalent to passing `--flag` (checked in `main` before dispatch; env wins if set).
|
||||
- **Blocked subcommands**: `ingest`, `ingest-file`, `ingest-stdin`, `reset` (all write-path commands). (`nuke` does not exist as a subcommand.)
|
||||
- **Allowed**: `search`, `ask`, `doctor`, `schema`, `mcp`, `tui` (read-path).
|
||||
- On block: exit code 1 + error output:
|
||||
- `--json` mode: `error.v1` ndjson to stderr (`code: "readonly_mode"`, `message: "kebab: readonly mode — mutating commands are disabled"`)
|
||||
- plain mode: single `kebab: readonly mode — mutating commands are disabled\n` to stderr
|
||||
- Implementation: `fn is_mutating(cmd: &Cmd) -> bool` + guard block in `main()` after flag parsing, before `match cli.cmd`.
|
||||
|
||||
#### `--quiet`
|
||||
|
||||
- Suppresses all human-readable stderr output: progress lines, hint messages.
|
||||
- Does **not** suppress `error.v1` ndjson (in `--json` mode) or plain error text — errors always reach stderr.
|
||||
- `--json` flag automatically implies `--quiet` behavior (already the case in practice; this makes it explicit and documented).
|
||||
- Implementation: extend `ProgressMode::Human { tty: bool }` → `Human { tty: bool, quiet: bool }`. Update `ProgressMode::from_flags(json: bool, quiet: bool, plain_env: bool) -> Self`. When `quiet=true` (or `--json`), `Human { tty: _, quiet: true }` overrides draw target to `hidden` and skips all `writeln!(stderr, ...)` calls in non-TTY branch. `ProgressDisplay::new(mode: ProgressMode)` signature unchanged.
|
||||
- `--quiet` without `--json` still emits `ingest_report.v1` to stdout at end (not suppressed).
|
||||
|
||||
#### New `error.v1` code
|
||||
|
||||
Construct `ErrorV1 { code: "readonly_mode", ... }` directly in the guard block in `main.rs` — no change to `classify()` (which dispatches on `anyhow::Error` types, not user-triggered state). Document the new code in `tasks/HOTFIXES.md`.
|
||||
|
||||
### Testing
|
||||
|
||||
- `kebab --readonly ingest` → exit 1, error message contains "readonly mode".
|
||||
- `kebab --readonly ingest --json` → exit 1, stderr contains `error.v1` with `code: "readonly_mode"`.
|
||||
- `KEBAB_READONLY=1 kebab ingest` → same as `--readonly`.
|
||||
- `kebab --readonly search "q"` → passes through normally.
|
||||
- `kebab --quiet ingest` → stderr silent during run, `ingest_report.v1` still on stdout.
|
||||
- `kebab ingest --json` → no human lines on stderr (auto-quiet behavior documented).
|
||||
|
||||
---
|
||||
|
||||
## Bundling rationale
|
||||
|
||||
All three changes are small and independent — no shared code paths. Bundled into one branch to avoid PR noise for minor UX polish. The CLAUDE.md fix is doc-only and safe to merge first if needed.
|
||||
|
||||
## Files changed (expected)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `CLAUDE.md` | schema list update |
|
||||
| `crates/kebab-cli/src/main.rs` | `--readonly`, `--quiet` global flags + guard block |
|
||||
| `crates/kebab-cli/src/progress.rs` | Aborted/Completed bug fix, `KEBAB_PROGRESS=plain`, quiet threading |
|
||||
| `crates/kebab-app/src/error_wire.rs` | `"readonly_mode"` code |
|
||||
| `tasks/HOTFIXES.md` | new entry for `readonly_mode` error code |
|
||||
| `tasks/p9/p9-fb-26-ingest-log-consistency.md` | status → merged |
|
||||
| `tasks/p9/p9-fb-28-agent-invocation-flags.md` | status → merged |
|
||||
| `tasks/INDEX.md` | status update |
|
||||
| `HANDOFF.md` | one-liner |
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
title: "p9-fb-31 — Single-file / stdin ingest — agent on-demand 저장"
|
||||
date: 2026-05-07
|
||||
status: design (brainstorm 완료, plan 단계 대기)
|
||||
target_version: 0.3.x
|
||||
task_spec: ../../../tasks/p9/p9-fb-31-single-file-stdin-ingest.md
|
||||
contract_source: ../specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 ingest, §6 filesystem, §10 UX]
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
---
|
||||
|
||||
# Single-file / stdin ingest — 설계
|
||||
|
||||
## 동기
|
||||
|
||||
agent (Claude Code via MCP, fb-30) 가 web 에서 fetch 한 markdown / pdf 를 KB 에 저장하려면 현재는:
|
||||
|
||||
1. agent 가 workspace 디렉토리에 file 쓰기.
|
||||
2. `kebab ingest` 전체 walk 재실행.
|
||||
|
||||
(2) 가 비효율 — 100+ doc workspace 면 모든 doc 의 incremental check 비용. agent 메모리상 string contents 면 임시 file 거치는 우회.
|
||||
|
||||
본 task 는 두 신규 명령 도입:
|
||||
|
||||
- `kebab ingest-file <path>` — 단일 file (workspace 외부 포함) 만 ingest.
|
||||
- `kebab ingest-stdin --title <T> [--source-uri <URI>]` — stdin 에서 markdown 본문 read 후 ingest.
|
||||
|
||||
MCP tool `ingest_file` + `ingest_stdin` 도 동시 추가 — agent 가 CLI 우회 없이 직접 호출.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
| 결정 | 선택 |
|
||||
|------|------|
|
||||
| 외부 file 저장 정책 | Copy in (`<workspace.root>/_external/<hash12>.<ext>`) |
|
||||
| CLI surface | 신규 subcommand 2개 (`ingest-file` + `ingest-stdin`) |
|
||||
| MCP tool | 동시 추가 (4 → 6 tool) — `ingest_file` + `ingest_stdin` |
|
||||
| .kebabignore | bypass + warn (explicit ingest 가 default bypass intent) |
|
||||
| stdin v1 scope | markdown 전용 + flag → frontmatter 자동 주입 |
|
||||
|
||||
## Surface 1 — `kebab ingest-file`
|
||||
|
||||
### CLI
|
||||
|
||||
```
|
||||
kebab ingest-file <path> [--config <path>]
|
||||
```
|
||||
|
||||
- `path`: positional, absolute / relative file path. workspace 외부 가능.
|
||||
- `--config <path>`: 기존 facade rule 일관 (P3-5 / P4-3 패턴).
|
||||
- 추가 flag 없음 — 명시 ingest 자체가 .kebabignore bypass intent.
|
||||
|
||||
### Behavior
|
||||
|
||||
1. file 존재 여부 + 크기 + media type (extension) 검증.
|
||||
2. workspace.root 의 `.kebabignore` pattern 과 source path 매치 검사 — 매치 시 stderr warn (`warn: <path> matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)`). 진행은 계속.
|
||||
3. blake3 content hash 계산 → `_external/<hash12>.<ext>` workspace 상대 경로 derive.
|
||||
4. `<workspace.root>/_external/` 디렉토리 자동 생성 (없으면). 첫 생성 시 `<workspace.root>/.kebabignore` 에 `_external/` line 자동 append (없으면) — 향후 walk 중복 방지.
|
||||
5. file content → `<workspace.root>/_external/<hash12>.<ext>` 로 copy. 동일 hash 면 skip (idempotent).
|
||||
6. 단일 asset 으로 기존 ingest pipeline 재사용 (parse → chunk → embed → vector store + SQLite upsert). incremental ingest (fb-23) 가 동일 hash 면 unchanged 처리.
|
||||
7. `IngestReport` (`ingest_report.v1`) 반환 — single asset count.
|
||||
|
||||
### Output
|
||||
|
||||
stdout 은 기존 `kebab ingest` 와 동일 — 사람 모드는 한 줄 summary, `--json` 은 `ingest_report.v1` JSON.
|
||||
|
||||
```text
|
||||
$ kebab ingest-file ~/Downloads/article.md
|
||||
ingested 1 new (~/Downloads/article.md → _external/a3f7b9e2c1d4.md)
|
||||
```
|
||||
|
||||
`--json`:
|
||||
|
||||
```json
|
||||
{"schema_version":"ingest_report.v1","scope":{"root":".../_external/a3f7b9e2c1d4.md","include":[],"exclude":[]},"scanned":1,"new":1,"updated":0,"skipped":0,"unchanged":0,"errors":0,...}
|
||||
```
|
||||
|
||||
(`scope.root` 표현은 plan 단계 결정 — 단일 file path 또는 fake scope.)
|
||||
|
||||
## Surface 2 — `kebab ingest-stdin`
|
||||
|
||||
### CLI
|
||||
|
||||
```
|
||||
kebab ingest-stdin --title <T> [--source-uri <URI>] [--config <path>]
|
||||
```
|
||||
|
||||
- `--title <T>`: 필수. frontmatter `title` field 채움.
|
||||
- `--source-uri <URI>`: 옵션. 제공 시 frontmatter `source_uri` field 채움.
|
||||
- v1 markdown 전용 — `--media` flag 없음.
|
||||
|
||||
### Behavior
|
||||
|
||||
1. stdin 전체 read → `String content`.
|
||||
2. **Frontmatter pre-check**: `content.trim_start().starts_with("---\n")` 면 — `Err`: `"stdin already has frontmatter; use \`kebab ingest-file\` for files with metadata"`. exit 2.
|
||||
3. 그 외 frontmatter block prepend:
|
||||
|
||||
```md
|
||||
---
|
||||
title: "<T>"
|
||||
source_uri: "<URI>" # only if --source-uri provided
|
||||
---
|
||||
|
||||
<stdin contents>
|
||||
```
|
||||
|
||||
(YAML escaping for title — `serde_yaml::to_string` 또는 inline quote escape. plan 단계 결정.)
|
||||
|
||||
4. 합친 markdown 의 blake3 hash → `<workspace.root>/_external/<hash12>.md` 로 write.
|
||||
5. ingest-file path 의 5-7 단계 재사용.
|
||||
|
||||
### Output
|
||||
|
||||
```text
|
||||
$ echo "## Body" | kebab ingest-stdin --title "Article X" --source-uri "https://example.com/x"
|
||||
ingested 1 new (stdin → _external/7c8e1f3a2b9d.md)
|
||||
```
|
||||
|
||||
`--json` 동일 `ingest_report.v1`.
|
||||
|
||||
### source_uri metadata 흐름
|
||||
|
||||
- frontmatter `source_uri` 는 markdown parser 가 `Document.metadata` 의 free-form map 에 string field 로 저장 (이미 처리됨 — frontmatter 의 모든 key 가 metadata 로 흘러감).
|
||||
- `kebab inspect` / `kebab search --json` 의 `doc_meta` 에 자동 포함. agent 가 search 결과의 source_uri 로 원본 web URL 추적 가능.
|
||||
- v1 wire schema 추가 변경 없음 — `metadata` 가 이미 free-form map.
|
||||
|
||||
## Surface 3 — MCP tools `ingest_file` + `ingest_stdin`
|
||||
|
||||
### `ingest_file`
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestFileInput {
|
||||
/// Absolute or relative path to the file to ingest.
|
||||
pub path: String,
|
||||
}
|
||||
```
|
||||
|
||||
facade: `kebab_app::ingest_file_with_config(cfg, &Path) -> Result<IngestReport>`.
|
||||
|
||||
handle: `spawn_blocking` wrap (touches embedder + SqliteStore). text content = `ingest_report.v1` JSON.
|
||||
|
||||
### `ingest_stdin`
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestStdinInput {
|
||||
/// Markdown body content. v1 supports markdown only.
|
||||
pub content: String,
|
||||
/// Title for frontmatter injection.
|
||||
pub title: String,
|
||||
/// Optional source URI (e.g. https URL agent fetched from).
|
||||
pub source_uri: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
facade: `kebab_app::ingest_stdin_with_config(cfg, content, title, source_uri) -> Result<IngestReport>`.
|
||||
|
||||
handle: `spawn_blocking` wrap. text content = `ingest_report.v1` JSON.
|
||||
|
||||
### `KebabHandler` 변경
|
||||
|
||||
- `build_tools_vec()` 가 4 → 6 entries 반환.
|
||||
- `call_tool` match 에 `"ingest_file"` + `"ingest_stdin"` arm 추가 (spawn_tool helper 재사용).
|
||||
- 신규 module `crates/kebab-mcp/src/tools/ingest_file.rs` + `ingest_stdin.rs`.
|
||||
|
||||
### Mutation tool 첫 도입
|
||||
|
||||
fb-30 v1 은 read-only 4 tool. fb-31 머지로 mutation surface 등장 — agent 가 KB 에 직접 write 가능. 의도된 진화 — agent flow 의 자연스러운 다음 단계. HOTFIXES entry 명시.
|
||||
|
||||
## `_external/` 디렉토리 정책
|
||||
|
||||
- 위치: `<workspace.root>/_external/`.
|
||||
- 첫 ingest-file / ingest-stdin 호출 시 자동 생성.
|
||||
- 생성과 동시에 `<workspace.root>/.kebabignore` 에 `_external/` line append (없으면) — 향후 `kebab ingest` 전체 walk 가 이 디렉토리 재 walk 안 함 (re-ingestion 무한 루프 방지).
|
||||
- 파일명 = `blake3(content) 12-char prefix + 원래 ext`. deterministic — 동일 content 재 ingest 면 같은 파일명, idempotent (incremental ingest 가 unchanged 처리).
|
||||
- 사용자가 `_external/` 안 파일 직접 수정해도 OK — explicit `ingest-file` 또는 manual `kebab ingest` (`.kebabignore` 우회 시) 가 incremental 변경 감지.
|
||||
|
||||
## 의존 경계 + 신규 facade
|
||||
|
||||
- `kebab-app::ingest_file_with_config(cfg, &Path) -> Result<IngestReport>` — 신규 facade fn.
|
||||
- `kebab-app::ingest_stdin_with_config(cfg, content, title, source_uri) -> Result<IngestReport>` — 신규.
|
||||
- 둘 모두 내부적으로 기존 `ingest_with_config_opts` 의 single-asset 변종 OR 별 helper. plan 단계 구체화.
|
||||
- frontmatter injection helper (`kebab-app::frontmatter::inject(content, title, source_uri) -> String`) — kebab-app 안. kebab-mcp + kebab-cli 둘 다 facade 통해 호출.
|
||||
- `_external/` 디렉토리 + `.kebabignore` 자동 추가도 `kebab-app` 책임 (ingest_file_with_config 안에서).
|
||||
|
||||
## Wire schema impact
|
||||
|
||||
**없음**. 모두 기존 schema 재사용:
|
||||
|
||||
- `ingest_report.v1` — single-asset count. 기존 shape 그대로.
|
||||
- `error.v1` — file not found / frontmatter precheck 등 facade error 가 기존 code 로 매핑 (`io_error`, `generic`).
|
||||
|
||||
`source_uri` 는 `Document.metadata` 의 free-form map 안 — `inspect` / `search_hit.v1` / `answer.v1` 의 `doc_meta` 에 자동 포함.
|
||||
|
||||
## Testing 전략
|
||||
|
||||
| crate | type | 파일 | 검증 |
|
||||
|-------|------|------|------|
|
||||
| `kebab-app` | unit | `tests/ingest_file.rs` | external file → `_external/<hash>.md` copy + IngestReport new=1, 두 번째 호출 unchanged=1, .kebabignore match warn (stderr capture), file-not-found Err |
|
||||
| `kebab-app` | unit | `tests/ingest_stdin.rs` | content + title → frontmatter prepend + ingest, source_uri 옵션 처리, stdin already-frontmatter Err |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_ingest_file.rs` | tool call → ingest_report.v1 (isError=false), idempotent 두 번째 호출 unchanged=1 |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_ingest_stdin.rs` | content + title input → ingest_report.v1, frontmatter precheck error 시 isError=true + error.v1 |
|
||||
| `kebab-mcp` | integration | `tests/tools_list.rs` (기존) | 4 → 6 tool 검증 (assertion update) |
|
||||
| `kebab-cli` | integration | `tests/cli_ingest_file.rs` | spawn `kebab ingest-file <tempfile>` → ingest_report.v1 stdout, exit 0 |
|
||||
| `kebab-cli` | integration | `tests/cli_ingest_stdin.rs` | spawn `kebab ingest-stdin --title X` + stdin pipe → ingest_report.v1, exit 0 |
|
||||
|
||||
## Spec / doc sync (PR 같은 commit)
|
||||
|
||||
1. **frozen design §3 / §6** — `_external/` 디렉토리 + .kebabignore auto-add 정책 명시.
|
||||
2. **README** — 명령 표 에 `kebab ingest-file` + `kebab ingest-stdin` 두 row + MCP usage section 의 tool list 4 → 6 update.
|
||||
3. **HANDOFF** — post-도그푸딩 entry.
|
||||
4. **CLAUDE.md** — wire schema 목록 변경 없음 (`ingest_report.v1` 재사용). `_external/` 디렉토리 + naming convention 한 줄.
|
||||
5. **integrations/claude-code/kebab/SKILL.md** — `ingest_file` / `ingest_stdin` MCP tool 사용 안내 + agent fetch flow 예시.
|
||||
6. **HOTFIXES** — 신규 entry. fb-30 v1 read-only 정책 변경 (mutation tool 도입) 명시.
|
||||
7. **`tasks/p9/p9-fb-31-single-file-stdin-ingest.md`** — status `open` → `completed`.
|
||||
|
||||
## Release trigger
|
||||
|
||||
0.3.1 → **0.3.2** patch — additive only (신규 subcommand + 신규 MCP tool, 기존 surface 동작 무영향, wire schema 변경 없음). pre-1.0 patch 정책 일관 (fb-30 도 0.3.1 patch 였음).
|
||||
|
||||
## Out of scope (defer)
|
||||
|
||||
- **PDF / image stdin** — binary stream + base64 처리 v2.
|
||||
- **다른 metadata field** (tags, language hint, custom kv) — `--title` + `--source-uri` 외 v2.
|
||||
- **자동 dedup by source_uri** — content hash 기반 dedup 은 incremental ingest 가 이미 처리. URI 별 lookup 은 별 task.
|
||||
- **Storage quota / TTL** — agent 무한 ingest 시 KB 비대 우려. monitor + 별 task.
|
||||
- **Frontmatter merge** (stdin 이 이미 frontmatter 보유 시 머지) — v1 은 error. user 가 경우에 맞게 ingest-file 사용.
|
||||
- **`--force-ignore` flag** — 명시 ingest 가 default bypass 라 flag 불필요.
|
||||
- **MCP `ingest_file` 의 multi-file batch** (`paths: Vec<String>`) — v1 single path. 여러 file 호출은 agent 가 N 회.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **`_external/` 디렉토리 명명**: underscore prefix 가 dotfile 만큼 강한 hide 신호 아님. 사용자 workspace listing 시 보임. README 에 명시.
|
||||
- **.kebabignore auto-append 의 idempotency**: file 이 이미 `_external/` line 보유 시 중복 append 안 함. 정확한 정합 검사 필요.
|
||||
- **YAML escaping**: title 에 quote / special char 포함 시 frontmatter parse 실패 위험. `serde_yaml` 사용 또는 strict escape.
|
||||
- **Mutation tool 의 input validation**: `ingest_stdin` 의 `content` 가 매우 클 경우 (수 MB markdown) 메모리 압박. v1 size limit 없음 — agent 책임. monitor + 별 task.
|
||||
- **agent 의 무한 ingest**: KB 비대 + cost (embedding). 사용자 쪽 monitoring + storage quota 별 task.
|
||||
- **`_external/` workspace 외부 이동 / 백업 정책**: workspace 안 일반 파일과 동일 — 사용자 백업 정책 일관.
|
||||
- **hash collision 확률**: blake3 12-char prefix = 48 bit. ~16M files 까지 안전 (birthday bound). single-user KB 에 충분. 충돌 시 first-write-wins (idempotency 와 동일 동작).
|
||||
@@ -5,7 +5,9 @@ description: Local knowledge base + RAG over the user's pre-indexed documents (w
|
||||
|
||||
# kebab — local KB / RAG access
|
||||
|
||||
`kebab` is a CLI installed at `~/.cargo/bin/kebab` (binary name: `kebab`). It indexes the user's personal documents and exposes them via lexical / vector / hybrid search and a local-LLM RAG answer. All output speaks frozen wire schema v1 — every JSON record carries a `schema_version` field.
|
||||
`kebab` indexes the user's personal documents and exposes them via lexical / vector / hybrid search and a local-LLM RAG answer. All output speaks frozen wire schema v1 — every JSON record carries a `schema_version` field.
|
||||
|
||||
Two surfaces ship: an **MCP server** (`kebab mcp`, preferred — process stays hot across calls) and a **CLI** (`~/.cargo/bin/kebab`, fallback for hosts without MCP).
|
||||
|
||||
## When to invoke
|
||||
|
||||
@@ -24,56 +26,62 @@ Trigger when the user's question matches **any** of:
|
||||
|
||||
User-specific trigger keywords (team names, system names, internal acronyms) belong in a per-user override of this SKILL.md, not in this repo-shipped version.
|
||||
|
||||
## Two surfaces, pick the right one
|
||||
## MCP tools (preferred)
|
||||
|
||||
### `kebab search` — when you need the source
|
||||
When `kebab` is registered as an MCP server (see `~/.claude/mcp.json` example below), six tools are exposed as `mcp__kebab__<name>`:
|
||||
|
||||
| tool | purpose | mutation |
|
||||
|------|---------|----------|
|
||||
| `mcp__kebab__search` | corpus search → `search_hit.v1[]` | no |
|
||||
| `mcp__kebab__ask` | RAG answer → `answer.v1` | no |
|
||||
| `mcp__kebab__schema` | capability discovery → `schema.v1` | no |
|
||||
| `mcp__kebab__doctor` | health check → `doctor.v1` | no |
|
||||
| `mcp__kebab__ingest_file` | save single file → `ingest_report.v1` | yes |
|
||||
| `mcp__kebab__ingest_stdin` | save markdown blob → `ingest_report.v1` | yes |
|
||||
|
||||
Mutation tools require explicit user intent — never auto-invoke.
|
||||
|
||||
### `mcp__kebab__search` — when you need the source
|
||||
|
||||
Use when the user wants to **find** a doc, or when you (the model) need raw chunks to reason from before answering.
|
||||
|
||||
```bash
|
||||
kebab search "<query>" --mode hybrid --json
|
||||
Input:
|
||||
```json
|
||||
{ "query": "<query>", "mode": "hybrid", "k": 10 }
|
||||
```
|
||||
|
||||
- `--mode hybrid` is the default-correct choice. Use `vector` for semantic-only ("docs about X concept"), `lexical` for exact strings ("the literal flag `--foo-bar`").
|
||||
- Output is a JSON array of `search_hit.v1` objects. Key fields: `rank`, `score`, `doc_path`, `heading_path[]`, `section_label`, `snippet`, `citation` (has line range / page), `chunk_id`.
|
||||
- `mode = "hybrid"` is the default-correct choice. Use `"vector"` for semantic-only ("docs about X concept"), `"lexical"` for exact strings ("the literal flag `--foo-bar`").
|
||||
- Output is `search_hit.v1` array. Key fields: `rank`, `score`, `doc_path`, `heading_path[]`, `section_label`, `snippet`, `citation` (line range / page), `chunk_id`.
|
||||
- Cite back to the user as `doc_path § heading_path[-1]` so they can open the source.
|
||||
|
||||
### `kebab ask` — when you need the answer
|
||||
### `mcp__kebab__ask` — when you need the answer
|
||||
|
||||
Use when the user wants a synthesized answer, not a list of links.
|
||||
|
||||
```bash
|
||||
kebab ask "<question>" --json
|
||||
Input:
|
||||
```json
|
||||
{ "query": "<question>", "session_id": "<optional-slug>", "mode": "hybrid" }
|
||||
```
|
||||
|
||||
- Returns one `answer.v1` object: `answer` (markdown), `citations[]`, `grounded` (bool), `refusal_reason`, `model`.
|
||||
- **If `grounded == false`** → the KB doesn't have enough context. Don't paraphrase the refusal as if it were an answer. Tell the user the KB came up dry and fall back to your own knowledge or ask for the source.
|
||||
- For follow-up turns on the same topic, pass `--session <stable-id>` so kebab gets prior history. Pick a slug (`team-onboarding-2026-05`) and reuse it across the conversation. Sessions persist across Claude sessions until `kebab reset --data-only`.
|
||||
- Returns `answer.v1`: `answer` (markdown), `citations[]`, `grounded` (bool), `refusal_reason`, `model`, `conversation_id`, `turn_index`.
|
||||
- **If `grounded == false`** → KB doesn't have enough context. Don't paraphrase the refusal as if it were an answer. Tell the user the KB came up dry and fall back to your own knowledge or ask for the source.
|
||||
- For follow-up turns on the same topic, pass `session_id` (e.g. `"team-onboarding-2026-05"`) and reuse it across the conversation. Sessions persist until `kebab reset --data-only`.
|
||||
|
||||
## Parsing tips
|
||||
## CLI fallback
|
||||
|
||||
- Both commands print **one JSON value to stdout**, progress / warnings to stderr. Capture stdout only: `kebab search ... --json 2>/dev/null`.
|
||||
- `search --json` output can be large for broad queries. Pipe through `jq` to project: `jq '.[] | {rank, doc_path, heading: .heading_path[-1], snippet}'`.
|
||||
- `ask --json`'s `citations[]` mirrors `search_hit.v1` minus retrieval internals — same `doc_path` / `citation` shape.
|
||||
- Schema reference lives in the kebab repo at `docs/wire-schema/v1/*.schema.json` if a field is unclear.
|
||||
|
||||
## Capability discovery
|
||||
|
||||
Before using streaming or multi-turn features, you can probe what this binary supports:
|
||||
If MCP tools aren't in scope (host without MCP support, or `mcp.json` not configured), call the CLI via Bash:
|
||||
|
||||
```bash
|
||||
kebab schema --json
|
||||
kebab search "<query>" --mode hybrid --json 2>/dev/null
|
||||
kebab ask "<question>" --json 2>/dev/null
|
||||
kebab ask "<question>" --session <stable-id> --json 2>/dev/null
|
||||
```
|
||||
|
||||
Returns a `schema.v1` object with: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), and `stats` (doc/chunk/asset count + last_ingest_at). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. This call is cheap (no LLM) and can be run once per session.
|
||||
Same wire shapes as MCP. CLI pays cold start (~1-2s) per call — prefer MCP when available.
|
||||
|
||||
## Quick health check
|
||||
## MCP host config
|
||||
|
||||
If a call fails or returns suspicious output, run `kebab doctor` first — it surfaces config-load / data-dir / Ollama-reachability problems in one line each. Don't silently retry on errors; report the doctor output.
|
||||
|
||||
## MCP server (recommended over CLI subprocess wrapping)
|
||||
|
||||
Since v0.4.0, `kebab` exposes an MCP (Model Context Protocol) stdio server. Configure once in `~/.claude/mcp.json`:
|
||||
Register `kebab mcp` once in your host's MCP config. For Claude Code, edit `~/.claude/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -86,32 +94,62 @@ Since v0.4.0, `kebab` exposes an MCP (Model Context Protocol) stdio server. Conf
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code spawns `kebab mcp` at session start; the process stays alive across all tool calls so SQLite / Lance / fastembed are hot after the first call. 4 tools available: `search` / `ask` / `schema` / `doctor`. Same wire shapes as the CLI `--json` mode — see `Two surfaces, pick the right one` above for the same guidance.
|
||||
Claude Code spawns `kebab mcp` at session start; the process stays alive across all tool calls so SQLite / Lance / fastembed are hot after the first call (~1-2s cold, sub-100ms thereafter). For Cursor / OpenAI Agents / Copilot CLI host examples plus per-tool input/output reference, see [docs/mcp-usage.md](../../../docs/mcp-usage.md) in the kebab repo.
|
||||
|
||||
If your host doesn't support MCP, the CLI subprocess pattern (`kebab search --json` / `kebab ask --json`) above continues to work.
|
||||
## Parsing tips
|
||||
|
||||
- MCP tools return JSON content blocks; CLI prints **one JSON value to stdout**, progress / warnings to stderr. Capture stdout only: `kebab search ... --json 2>/dev/null`.
|
||||
- `search` output can be large for broad queries. Project relevant fields when summarizing — for CLI: `jq '.[] | {rank, doc_path, heading: .heading_path[-1], snippet}'`.
|
||||
- `ask`'s `citations[]` mirrors `search_hit.v1` minus retrieval internals — same `doc_path` / `citation` shape.
|
||||
- Schema reference lives in the kebab repo at `docs/wire-schema/v1/*.schema.json` if a field is unclear.
|
||||
|
||||
## Capability discovery
|
||||
|
||||
Before using streaming or multi-turn features, probe what this binary supports — call `mcp__kebab__schema` (or CLI `kebab schema --json`):
|
||||
|
||||
Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (doc/chunk/asset count + last_ingest_at). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. Cheap call (no LLM), once per session.
|
||||
|
||||
## Quick health check
|
||||
|
||||
If a call fails or returns suspicious output, call `mcp__kebab__doctor` (or CLI `kebab doctor`) first — it surfaces config-load / data-dir / Ollama-reachability problems in one line each. Don't silently retry on errors; report the doctor output.
|
||||
|
||||
## Workflow recipes
|
||||
|
||||
**Recipe A — user asks an internal-context question, you want grounded answer:**
|
||||
|
||||
1. `kebab ask "<question>" --json`
|
||||
2. If `grounded`, cite `citations[].doc_path` in your reply and quote the user's `answer` (translate / condense as needed).
|
||||
3. If `!grounded`, switch to `kebab search "<question>" --mode hybrid --json` and look at top 3 hits — sometimes content exists but RAG threshold rejected it. If hits look relevant, summarize from snippets and cite. If still nothing, tell the user.
|
||||
1. Call `mcp__kebab__ask` (or CLI `kebab ask "<question>" --json`).
|
||||
2. If `grounded`, cite `citations[].doc_path` in your reply and quote the `answer` (translate / condense as needed).
|
||||
3. If `!grounded`, call `mcp__kebab__search` with the same query and look at top 3 hits — sometimes content exists but RAG threshold rejected it. If hits look relevant, summarize from snippets and cite. If still nothing, tell the user.
|
||||
|
||||
**Recipe B — domain question where internal context might exist:**
|
||||
|
||||
1. Run `kebab search "<key terms>" --mode hybrid --json` quickly (cheap, no LLM).
|
||||
1. Call `mcp__kebab__search` with key terms (cheap — no LLM).
|
||||
2. If top hit's `score` is low (< ~0.3) or no hits, answer from general knowledge without mentioning the KB.
|
||||
3. If top hit is relevant, fold its content into your answer and cite `doc_path`.
|
||||
|
||||
**Recipe C — user wants to know "what's in the KB about X":**
|
||||
|
||||
1. `kebab search "X" --mode hybrid --json | jq '.[] | {doc_path, heading: .heading_path[-1]}'`
|
||||
1. Call `mcp__kebab__search` with the topic.
|
||||
2. List unique `doc_path`s back to the user as a discovery surface.
|
||||
|
||||
**Recipe D — agent fetched a web doc, save to KB:**
|
||||
|
||||
When you've fetched a markdown article (e.g. via WebFetch) that the user might query later:
|
||||
|
||||
1. Call `mcp__kebab__ingest_stdin` with:
|
||||
- `content`: the markdown body
|
||||
- `title`: a stable title (article H1 or page title)
|
||||
- `source_uri`: the URL you fetched from
|
||||
|
||||
The doc lands in `<workspace.root>/_external/<hash>.md` and is indexed for `search` / `ask` immediately. Subsequent calls with identical content are no-ops (content-hash dedup).
|
||||
|
||||
Don't loop ingest the same article — dedup makes it safe but wastes embedding cost.
|
||||
|
||||
For files already on disk the user references, prefer `mcp__kebab__ingest_file` with the path — kebab handles the copy + dedup.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't run `kebab ingest` / `kebab reset` / `kebab init` automatically. Those mutate state — the user runs them.
|
||||
- Don't auto-invoke `mcp__kebab__ingest_file` / `mcp__kebab__ingest_stdin` / `kebab ingest` / `kebab reset` / `kebab init`. Those mutate state — the user must explicitly request.
|
||||
- Don't pass user-supplied raw text into the query without trimming — long queries (> a few hundred chars) waste embedding budget. Extract the question.
|
||||
- Don't fabricate `doc_path`s. If you didn't see a doc in `search` / `ask` output, it's not in the KB.
|
||||
- Don't use `kebab tui` from a skill — it's interactive only.
|
||||
|
||||
@@ -14,6 +14,72 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-05-07 (2)
|
||||
|
||||
### macOS XDG path collision: `data_dir` == `config_dir` → DataOnly reset deletes config
|
||||
|
||||
- **File**: `crates/kebab-config/src/lib.rs`
|
||||
- **Root cause**: `dirs` crate 가 macOS 에서 `config_dir()` 과 `data_dir()` 모두 `~/Library/Application Support/` 반환. `ResetScope::DataOnly` 가 `data_dir` 을 삭제하면 config 파일까지 함께 삭제됨.
|
||||
- **Fix**: `xdg_config_path`, `xdg_data_dir`, `xdg_cache_dir` 의 `dirs` fallback 제거 → `$HOME/.config`, `$HOME/.local/share`, `$HOME/.cache` 직접 사용 (XDG 표준, 플랫폼 무관).
|
||||
- **Migration**: `Config::load(None)` 에서 새 경로 없고 macOS legacy (`~/Library/Application Support/kebab/config.toml`) 있으면 자동 copy + stderr 안내.
|
||||
- **New paths** (macOS):
|
||||
- config: `~/.config/kebab/config.toml` (was `~/Library/Application Support/kebab/config.toml`)
|
||||
- data: `~/.local/share/kebab/` (was `~/Library/Application Support/kebab/`)
|
||||
- cache: `~/.cache/kebab/` (was `~/Library/Caches/kebab/`)
|
||||
- state: `~/.local/state/kebab/` (unchanged)
|
||||
|
||||
## 2026-05-07
|
||||
|
||||
### fb-26: ingest 로그 `Aborted` 무조건 writeln + `Completed` TTY 요약 없음
|
||||
|
||||
- **File**: `crates/kebab-cli/src/progress.rs`
|
||||
- `Aborted` 핸들러가 TTY 모드에서도 무조건 `writeln!` 하여 `bar.abandon_with_message` 아래에 중복 출력 발생. Fixed: `if !tty && !quiet` 로 가드.
|
||||
- `Completed` TTY 경로가 `bar.finish_and_clear()` 호출 후 요약 라인 없음. Fixed: `!quiet` 일 때 항상 `ingest: complete (...)` writeln 출력.
|
||||
- `KEBAB_PROGRESS=plain` env override 추가 — CI pty wrapper 에서 TTY 감지 강제 제거.
|
||||
- `ProgressMode::Human` 에 `quiet: bool` 필드 추가; `--quiet` flag 전체 progress stderr 억제.
|
||||
|
||||
### fb-28: `--readonly` / `--quiet` 전역 flag + `readonly_mode` error code
|
||||
|
||||
- **File**: `crates/kebab-cli/src/main.rs`
|
||||
- `--readonly` (또는 `KEBAB_READONLY=1`) — mutating subcommand (`ingest`, `ingest-file`, `ingest-stdin`, `reset`) 차단. exit code 1.
|
||||
- `--json --readonly` — stderr 로 `error.v1` 신규 code: `"readonly_mode"` emit.
|
||||
- `--quiet` — 모든 human-readable stderr (progress, hint) 억제; error 는 여전히 stderr 도달.
|
||||
- `--json` 자동 quiet 함축 (명시적 현재).
|
||||
- `error.v1` code: `"readonly_mode"` main() guard block 에서 직접 construction (classify() 경로 아님).
|
||||
|
||||
## 2026-05-07 — p9-fb-31 (post-dogfooding): single-file / stdin ingest
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-06 — agent (Claude Code via MCP, fb-30) 가 web fetch 한 markdown / 단일 외부 file 을 KB 에 저장하려면 `kebab ingest` 전체 walk 재실행 비효율. agent 메모리상 string contents 도 stdin ingest 가능해야.
|
||||
|
||||
**Live binding 변경**:
|
||||
|
||||
- 신규 subcommand `kebab ingest-file <path>` — 단일 file ingest, workspace 외부 path 가능.
|
||||
- 신규 subcommand `kebab ingest-stdin --title <T> [--source-uri <URI>]` — stdin 의 markdown 본문 ingest, v1 markdown only.
|
||||
- 신규 MCP tool `ingest_file` + `ingest_stdin` — fb-30 v1 read-only 정책 변경, 첫 mutation surface 도입 (의도된 진화). tools/list 4 → 6.
|
||||
- 외부 file 저장 정책: `<workspace.root>/_external/<blake3-12>.<ext>` 로 copy. deterministic 명명 → idempotent. `_external/` 첫 생성 시 `.kebabignore` 자동 append (walk 무한 루프 방지).
|
||||
- `.kebabignore` 매치 시 stderr warn (`warn: <path> matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)`) 후 진행. `--force-ignore` flag 불필요 — explicit ingest 가 default bypass intent.
|
||||
- stdin frontmatter 처리: 본문이 `---` 으로 시작하면 error (`use kebab ingest-file`); 그 외 frontmatter block prepend (title + 옵션 source_uri, YAML 더블쿼트 escape).
|
||||
- `kebab-app::external` 신규 모듈 — `ensure_external_dir`, `ensure_kebabignore_entry`, `copy_to_external`, `inject_frontmatter` helper. kebab-cli + kebab-mcp 둘 다 facade 통해 호출.
|
||||
- `kebab-app::ingest_file_with_config` + `ingest_stdin_with_config` 신규 facade fn.
|
||||
|
||||
**Spec contract impact**: design §6 에 `_external/` subdirectory 절 추가 (실제 §6.7 — 기존 §6 sub-section 이 6.6 까지 채워져 있어 §6.7 로 부착됨; spec stub 의 §6.3 명시는 deviation).
|
||||
|
||||
**Tests added**: kebab-app external::tests (14: dir / kebabignore append / copy / inject_frontmatter / yaml_quote), kebab-app integration (3 + 3: ingest_file + ingest_stdin), kebab-cli integration (2: cli_ingest_file + cli_ingest_stdin spawn-based), kebab-mcp integration (1 + 2: tools_call_ingest_file + tools_call_ingest_stdin), tools_list assertion update (4 → 6).
|
||||
|
||||
**Known limitation (deferred)**:
|
||||
|
||||
- PDF / image stdin — binary stream + base64 처리 v2.
|
||||
- `--title` + `--source-uri` 외 metadata field (tags, language, custom kv) — v2.
|
||||
- 자동 dedup by source_uri — content hash 기반 dedup 만 (incremental ingest). URI lookup 별 task.
|
||||
- Storage quota / TTL — agent 무한 ingest 시 KB 비대 우려. monitor + 별 task.
|
||||
- frontmatter merge (stdin 이 이미 frontmatter 보유 시 머지) — v1 은 error.
|
||||
- MCP `ingest_file` 의 multi-file batch 입력 — v1 single path. 여러 file 호출은 agent 가 N 회.
|
||||
|
||||
**Amends**:
|
||||
- design §6 (`_external/` subdirectory subsection 추가, §6.7 위치).
|
||||
- spec `tasks/p9/p9-fb-31-single-file-stdin-ingest.md` (status `open` → `completed`).
|
||||
- spec stub 의 §6.3 명시 → 실제 §6.7 (기존 §6 구조 우선).
|
||||
|
||||
## 2026-05-07 — p9-fb-30 (post-dogfooding): MCP server (stdio) — agent integration MVP
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI agent 가 kebab CLI 를 사용하는 것이 궁극 목표. 현재 surface 는 Claude Code 전용 skill (subprocess wrapper) 만 — host 무관 표준 통신 없음. fb-29 HTTP daemon 은 single-user local-first 환경 대비 비대로 deferred (2026-05-07), fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 없이 제공.
|
||||
|
||||
@@ -109,15 +109,15 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
|
||||
- [p9-fb-23 incremental ingest (post-도그푸딩)](p9/p9-fb-23-incremental-ingest.md)
|
||||
- [p9-fb-24 status bar + Library header + page scroll (post-도그푸딩)](p9/p9-fb-24-tui-affordances.md)
|
||||
- [p9-fb-25 config workspace.include 제거 + 지원 형식 가시성 (post-도그푸딩)](p9/p9-fb-25-config-include-removal.md)
|
||||
- **⏳ fb-26 ~ fb-42: 백로그 only — 미구현 + brainstorm 선행 필요.** spec 작성 시 [superpowers:brainstorming](../docs/superpowers/) 부터 시작. status: open. 다른 세션에서 이 그룹 손대기 전 사용자 확인 필요. **번호 = release 순서** — 작은 번호일수록 먼저 작업 (2026-05-06 renumber).
|
||||
- **⏳ fb-32 ~ fb-42: 백로그 only — 미구현 + brainstorm 선행 필요.** spec 작성 시 [superpowers:brainstorming](../docs/superpowers/) 부터 시작. status: open. 다른 세션에서 이 그룹 손대기 전 사용자 확인 필요. **번호 = release 순서** — 작은 번호일수록 먼저 작업 (2026-05-06 renumber).
|
||||
|
||||
### 🎯 0.3.0+ — agent foundation (MCP + introspection)
|
||||
- [p9-fb-26 ingest 로그 출력 일관성](p9/p9-fb-26-ingest-log-consistency.md) — ⏳ 미구현, brainstorm 필요
|
||||
### 🎯 0.3.x — agent foundation (MCP + introspection) ✅ 완료
|
||||
- [p9-fb-26 ingest 로그 출력 일관성](p9/p9-fb-26-ingest-log-consistency.md) — ✅ 머지 (2026-05-07)
|
||||
- [p9-fb-27 introspection + structured error wire](p9/p9-fb-27-introspection-and-error-wire.md) — ✅ 머지 + v0.3.0 cut (2026-05-07)
|
||||
- [p9-fb-28 agent invocation flags (--readonly / --quiet)](p9/p9-fb-28-agent-invocation-flags.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-28 agent invocation flags (--readonly / --quiet)](p9/p9-fb-28-agent-invocation-flags.md) — ✅ 머지 (2026-05-07)
|
||||
- [p9-fb-29 HTTP daemon (`kebab serve`)](p9/p9-fb-29-http-daemon.md) — 🚫 deferred (2026-05-07) — fb-30 stdio MCP 가 동일 가치 제공, daemon 복잡도 회피. P+ 재개 trigger 는 spec 참조.
|
||||
- [p9-fb-30 MCP server](p9/p9-fb-30-mcp-server.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27 ✅, stdio-only)
|
||||
- [p9-fb-31 single-file / stdin ingest](p9/p9-fb-31-single-file-stdin-ingest.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-30 MCP server](p9/p9-fb-30-mcp-server.md) — ✅ 머지 + v0.3.1 cut (2026-05-07)
|
||||
- [p9-fb-31 single-file / stdin ingest](p9/p9-fb-31-single-file-stdin-ingest.md) — ✅ 머지 + v0.3.2 cut (2026-05-07)
|
||||
|
||||
### 🎯 0.4.0 — agent surface refinement (additive only)
|
||||
- [p9-fb-32 stale doc indicator](p9/p9-fb-32-stale-doc-indicator.md) — ⏳ 미구현, brainstorm 필요
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-cli
|
||||
task_id: p9-fb-26
|
||||
title: "Ingest 로그 출력 일관성 (in-place vs 새 줄 혼재)"
|
||||
status: open
|
||||
status: merged
|
||||
target_version: 0.3.0
|
||||
depends_on: [p9-fb-02]
|
||||
unblocks: []
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-cli + kebab-app
|
||||
task_id: p9-fb-28
|
||||
title: "Agent invocation flags (--readonly + --quiet)"
|
||||
status: open
|
||||
status: merged
|
||||
target_version: 0.3.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-cli + kebab-app
|
||||
task_id: p9-fb-31
|
||||
title: "Single-file / stdin ingest — agent on-demand 저장"
|
||||
status: open
|
||||
status: completed
|
||||
target_version: 0.3.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
@@ -14,7 +14,7 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 읽은 article
|
||||
|
||||
# p9-fb-31 — Single-file / stdin ingest
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. workspace 외부 file 의 저장 위치 / metadata 입력 방식 / .kebabignore 우회 정책 brainstorm 후 확정.
|
||||
> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-31` 항목 참조 — live source of truth.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
|
||||
Reference in New Issue
Block a user