feat(fb-26,fb-28): ingest log consistency + --readonly/--quiet flags + schema sync #113
@@ -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.
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ 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 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 을 호출해야 함. 두 번 같은 모양으로 회귀했음.
|
||||
|
||||
@@ -86,6 +86,8 @@ kebab doctor
|
||||
|
||||
모든 명령에 `--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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
//! `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;
|
||||
@@ -32,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,
|
||||
}
|
||||
@@ -140,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>,
|
||||
},
|
||||
@@ -285,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 {
|
||||
@@ -295,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 {
|
||||
|
claude-reviewer-01
commented
[nit — pre-existing] **[nit — pre-existing]** `proceed without a guard rather than crashing — \`kb\` is still usable.` 에 `kb` 잔존. 모듈 doc comment 는 이번 PR 에서 수정됐는데 이 인라인 주석은 놓침. 다음 `kb` sweep 때 `kebab` 로 정정 권장.
|
||||
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) => {
|
||||
@@ -348,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(())
|
||||
}
|
||||
@@ -370,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)
|
||||
@@ -614,7 +659,9 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
if !confirm_destructive(scope, &paths, bytes)? {
|
||||
eprintln!("aborted.");
|
||||
if !cli.quiet {
|
||||
eprintln!("aborted.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -857,6 +904,13 @@ fn print_schema_text(s: &kebab_app::SchemaV1) {
|
||||
println!(" last_ingest_at {last}");
|
||||
}
|
||||
|
||||
fn is_mutating(cmd: &Cmd) -> bool {
|
||||
|
claude-reviewer-01
commented
[칭찬] **[칭찬]** `is_mutating` 의 `matches!` 패턴에 `_` 폴백이 없다 — 새 mutating subcommand 추가 시 컴파일 에러로 guard 업데이트를 강제한다. 안전한 설계.
|
||||
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)");
|
||||
}
|
||||
@@ -140,7 +146,7 @@ impl ProgressDisplay {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message(format!("{media} {path}"));
|
||||
}
|
||||
if !tty {
|
||||
if !tty && !quiet {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} {media} {path}");
|
||||
}
|
||||
@@ -154,7 +160,9 @@ impl ProgressDisplay {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if !tty {
|
||||
// Always emit summary in both TTY and non-TTY (unless quiet).
|
||||
|
claude-reviewer-01
commented
[칭찬] **[칭찬]** `Completed` fix — `!tty` 제거하고 `!quiet` 로 교체한 것이 맞다. TTY/non-TTY 양쪽에서 summary 한 줄이 항상 나와야 하고, 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 +183,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 +228,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();
|
||||
|
||||
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]
|
||||
|
claude-reviewer-01
commented
[칭찬] **[칭찬]** `readonly_json_mode_emits_error_v1` 가 `schema_version` 과 `code` 를 분리해서 assertion 한다 — exit code 만 확인하는 것보다 wire contract 를 훨씬 단단하게 잡는다.
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 대신).
|
||||
|
||||
## 명령 시퀀스
|
||||
|
||||
|
||||
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"
|
||||
```
|
||||
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 |
|
||||
@@ -14,6 +14,25 @@ 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
|
||||
|
||||
### 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 가능해야.
|
||||
|
||||
@@ -112,9 +112,9 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
|
||||
- **⏳ fb-26 ~ 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 필요
|
||||
- [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 필요
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user
[칭찬]
parse_bool_envcustom value parser — clap 기본 bool env 처리는"true"/"false"만 허용하지만,KEBAB_READONLY=1관용을 지원하려면 이 커스텀 파서가 필요하다. 선택이 맞다.