Files
kebab/docs/superpowers/plans/2026-05-07-fb-26-fb-28-agent-ux.md
2026-05-07 19:18:27 +09:00

25 KiB
Raw Blame History

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: openstatus: merged
tasks/p9/p9-fb-28-agent-invocation-flags.md status: openstatus: 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
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
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:

#[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:

#[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)
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:

#[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):

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):

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
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:

let mode = progress::ProgressMode::from_flags(cli.json);

Replace with a temporary stub that compiles (full implementation in Task 4):

let mode = progress::ProgressMode::from_flags(cli.json, false, false);
  • Step 6: Verify workspace compiles
cargo build -p kebab-cli 2>&1 | tail -5

Expected: Finished dev with no errors.

  • Step 7: Commit
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. Abortedwriteln! 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 99191 in progress.rs) with:

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
cargo test -p kebab-cli --lib 2>&1 | tail -10

Expected: all pass.

  • Step 3: Run integration tests that cover progress
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
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:

/// 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:

/// 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:

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):

let mode = progress::ProgressMode::from_flags(cli.json, false, false);

Replace with:

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
cargo build -p kebab-cli 2>&1 | tail -5

Expected: Finished dev with no errors.

  • Step 6: Quick smoke — readonly blocks ingest
# 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.

# 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
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:

//! 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:

#[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
cargo build -p kebab-cli 2>&1 | tail -5
  • Step 4: Run new tests
cargo test -p kebab-cli --test cli_readonly_quiet 2>&1 | tail -20

Expected: all 7 tests pass.

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
cargo test -p kebab-cli 2>&1 | tail -20

Expected: all pass.

  • Step 6: Commit
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):

## 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:

status: open

to:

status: merged

In tasks/p9/p9-fb-28-agent-invocation-flags.md, change:

status: open

to:

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
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"