diff --git a/docs/superpowers/plans/2026-05-07-fb-26-fb-28-agent-ux.md b/docs/superpowers/plans/2026-05-07-fb-26-fb-28-agent-ux.md new file mode 100644 index 0000000..4a34075 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-fb-26-fb-28-agent-ux.md @@ -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" +```