From 486e0d51e6a7b14a6a1f227bb9367bd8b0b8e260 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:03:31 -0700 Subject: [PATCH 01/76] kres: don't override settings.models.slow when --slow is unset ~/.kres/settings.json configures the slow-agent model per user, but the banner has been reporting claude-sonnet-4-6 on every run even when settings.json selects a different model. The override fires silently because --slow had a clap default_value of "sonnet". The override block in run_repl unconditionally fed args.slow through slow_tag_to_model_id(), mapping the unintentional default to claude-sonnet-4-6 and clobbering whatever was in settings.models.slow. Change ReplArgs.slow to Option. The slow-agent config-file resolver still defaults to "sonnet" so ~/.kres/slow-code-agent-sonnet.json is found when --slow isn't passed; the model-id override only fires when args.slow is Some(_). Signed-off-by: Chris Mason --- kres/src/main.rs | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/kres/src/main.rs b/kres/src/main.rs index 1812c27..cc0b3ae 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -44,12 +44,14 @@ struct ReplArgs { #[arg(long)] fast_agent: Option, /// Slow agent tag — picks ~/.kres/slow-code-agent-.json - /// (or the shipped configs/ default). Default: sonnet. Shipped - /// tags: sonnet, opus. When the tag is a known shorthand - /// (sonnet/opus) it also overrides the slow model id from - /// settings.json; pass --slow-model to override explicitly. - #[arg(long, default_value = "sonnet")] - slow: String, + /// (or the shipped configs/ default). When omitted the file + /// resolver falls back to "sonnet" but the slow model id from + /// settings.json is left alone. When passed AND the tag is a + /// known shorthand (sonnet/opus) the matching model id ALSO + /// overrides settings.models.slow — pass --slow-model to + /// override the model independently. + #[arg(long)] + slow: Option, /// Explicit slow-agent config path (overrides --slow). #[arg(long)] slow_agent: Option, @@ -359,7 +361,11 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // --slow is a tag; --slow-agent is an explicit path override. // Resolution for --slow: prefer ~/.kres/slow-code-agent-.json, // then fall back to /configs/slow-code-agent-.json. - let slow_tag_name = format!("slow-code-agent-{}.json", args.slow); + // When the operator didn't pass --slow at all, default the file + // resolver to "sonnet" — the model id is left to settings.json + // (see the override block below). + let slow_tag_for_file = args.slow.as_deref().unwrap_or("sonnet"); + let slow_tag_name = format!("slow-code-agent-{}.json", slow_tag_for_file); let slow_agent = args .slow_agent .clone() @@ -404,8 +410,16 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // it to a model id, so `--slow sonnet` actually switches the // slow model. Explicit --slow-model still beats the tag mapping. let mut settings = kres_repl::Settings::load_default(); - if let Some(id) = slow_tag_to_model_id(&args.slow) { - settings.set_model(kres_repl::ModelRole::Slow, Some(id.to_string())); + // Only map the --slow tag to a model id when the operator + // actually passed --slow. Without this gate the clap default + // "sonnet" would unconditionally overwrite settings.models.slow + // every run, masking whatever the operator set in + // ~/.kres/settings.json (user report 2026-04-21: settings.json + // said claude-mythos-preview, banner reported claude-sonnet-4-6). + if let Some(tag) = args.slow.as_deref() { + if let Some(id) = slow_tag_to_model_id(tag) { + settings.set_model(kres_repl::ModelRole::Slow, Some(id.to_string())); + } } settings.set_model(kres_repl::ModelRole::Fast, args.fast_model.clone()); settings.set_model(kres_repl::ModelRole::Slow, args.slow_model.clone()); @@ -980,9 +994,18 @@ mod tests { } #[test] - fn slow_tag_default_is_sonnet() { + fn slow_tag_unset_when_not_passed() { + // --slow is now Option with no clap default, so the + // settings.json slow model is not silently overridden when + // the operator omits the flag (user report 2026-04-21). let c = Cli::try_parse_from(["kres"]).unwrap(); - assert_eq!(c.repl.slow, "sonnet"); + assert_eq!(c.repl.slow, None); + } + + #[test] + fn slow_tag_passes_through_when_set() { + let c = Cli::try_parse_from(["kres", "--slow", "opus"]).unwrap(); + assert_eq!(c.repl.slow.as_deref(), Some("opus")); } #[test] From a4a4d500f49935463ceb077ec3ecbb43a55cedc1 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:16:20 -0700 Subject: [PATCH 02/76] kres-repl: reset the scroll region around the /edit editor spawn /edit launches $EDITOR (vim by default) on a scratch file. On this host Esc produced on-screen garbage instead of reaching vim, making the editor unusable. kres installs a DECSTBM scroll region at REPL startup (kres-repl/src/status.rs:50, ESC[1;{bottom}r reserves rows H-1 and H for the status line + prompt). cmd_edit spawns the editor without resetting that region first, so the editor takes over a terminal whose bottom two rows are excluded from scroll. Its cursor math and input decoding drift against that constraint; Esc and other key sequences echo as visible escape-sequence text instead of reaching the editor. Call crate::status::restore() before the spawn_blocking that runs the editor and crate::status::install() afterwards. The editor now starts with a full-height scroll region (same shape it would see if launched from the operator's shell directly), and kres's status row is re-established on return so the REPL prompt and background status poller keep working. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 3be172a..2aaa913 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -1912,6 +1912,15 @@ impl Session { println!("/edit: create tempfile: {e}"); return; } + // Tear down kres's DECSTBM scroll region (status.rs:50) and + // clear the status row BEFORE handing the terminal to the + // editor. Without this, vim/nvim paint into a terminal + // whose bottom two rows sit outside the scroll region: the + // editor's cursor math and input decoding drift, and key + // sequences (notably Esc) echo as on-screen garbage + // instead of reaching the editor. User report 2026-04-21: + // "Escape key doesn't work". Reinstalled on return. + crate::status::restore(); // Handing the terminal to the editor requires blocking on // its status. spawn_blocking keeps the runtime alive. let editor_path = tmp.clone(); @@ -1922,6 +1931,10 @@ impl Session { .status() }) .await; + // Reinstall the scroll region so the status row and REPL + // prompt re-appear. The background status poller will + // repaint the row on its next tick. + let _ = crate::status::install(); // Trust the tempfile contents regardless of editor exit code. // A `:wq!` forced-quit or the // user saving and escaping without a clean exit shouldn't From b709d523a97fc24c0a756a3329dab212e8dcb692 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:32:12 -0700 Subject: [PATCH 03/76] kres-repl: pause the status-row repainter while /edit holds the tty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even with the scroll region reset around the editor spawn, the in-editor cursor drifts after pressing Esc: the repaint shows up elsewhere and the caret lands in the wrong column. The status-row paint task (Self::run, the tokio::spawn at kres-repl/src/session.rs:465) ticks every 200ms. On each tick it writes status::paint output to stderr, which absolute-positions the cursor to row H-1, clears the line, writes text, and restores the cursor. stderr is the editor's output fd too, so vim's frame gets scribbled through, and the save-and-restore cursor dance drags the visible caret around independently of vim's own cursor state. The size-check branch is worse — it can re-install the scroll region behind the child's back. Add a Session::status_paused AtomicBool. cmd_edit sets it before handing the terminal off to the editor and clears it on return. The paint loop consults the flag at the top of each tick and continues without doing any work (including the size-check + install call) while the child owns the terminal. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 2aaa913..20407ce 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -221,6 +221,13 @@ pub struct Session { /// was still sitting in the todo list — which is NOT what an /// operator who just hit Ctrl-C's moral equivalent wants. stop_latched: Arc, + /// Pauses the 200ms status-row repainter while a child process + /// (vim launched by /edit, for instance) has the terminal. + /// Without this, the repainter absolute-positions to row H-1 + /// every tick and scribbles through the child's display, making + /// the child's cursor drift visibly. Set in cmd_edit before + /// spawn, cleared after return. + status_paused: Arc, /// §50: handles to every spawned MCP child process. On REPL /// exit we walk these and call `shutdown(2s)` on each so /// tracebacks flush cleanly instead of the child getting @@ -316,6 +323,7 @@ impl Session { turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), + status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), }; } @@ -344,6 +352,7 @@ impl Session { turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), + status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), } } @@ -452,6 +461,13 @@ impl Session { // handler re-runs install() and overwrites this. let status_geom_shared: Arc>> = Arc::new(tokio::sync::RwLock::new(status_geom)); + // Pause flag for the paint task. /edit and /stop set it so a + // child process that's taken over the terminal (vim, say) + // doesn't get its display scribbled over every 200 ms by + // the status-row repainter. Cleared when the child exits. + self.status_paused + .store(false, std::sync::atomic::Ordering::Release); + let status_paused_for_paint = self.status_paused.clone(); // Paint task: every 200ms repaint the status row. Every // ~1s (every 5 paint ticks) also poll term_size() — if the // terminal has resized since last check, clear the screen @@ -467,6 +483,16 @@ impl Session { let mut ticks_since_size_check: u32 = 0; loop { ticker.tick().await; + // Skip the whole tick when something (cmd_edit, + // etc.) has the terminal: the size-check branch + // would re-install the scroll region behind the + // child's back, and paint() would scribble + // across the child's frame. + if status_paused_for_paint + .load(std::sync::atomic::Ordering::Acquire) + { + continue; + } ticks_since_size_check += 1; if ticks_since_size_check >= 5 { ticks_since_size_check = 0; @@ -1920,6 +1946,14 @@ impl Session { // sequences (notably Esc) echo as on-screen garbage // instead of reaching the editor. User report 2026-04-21: // "Escape key doesn't work". Reinstalled on return. + // + // Also pause the 200ms status-row repainter (see the paint + // task in Self::run). Without this, the painter continues + // to absolute-position to row H-1 and write to stderr + // every tick, scribbling across vim's frame and dragging + // the visible cursor around. Cleared on return. + self.status_paused + .store(true, std::sync::atomic::Ordering::Release); crate::status::restore(); // Handing the terminal to the editor requires blocking on // its status. spawn_blocking keeps the runtime alive. @@ -1932,9 +1966,11 @@ impl Session { }) .await; // Reinstall the scroll region so the status row and REPL - // prompt re-appear. The background status poller will - // repaint the row on its next tick. + // prompt re-appear, then un-pause the status painter so it + // repaints the row on its next tick. let _ = crate::status::install(); + self.status_paused + .store(false, std::sync::atomic::Ordering::Release); // Trust the tempfile contents regardless of editor exit code. // A `:wq!` forced-quit or the // user saving and escaping without a clean exit shouldn't From b2c3476c8942ff4b9797a8546d07ea2713532f18 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:37:32 -0700 Subject: [PATCH 04/76] kres-repl: block rustyline readline while /edit holds the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even with the status painter paused and the scroll region reset around the editor spawn, the "> " prompt keeps redrawing on top of vim's frame while the editor is open. read_stdin (kres-repl/src/session.rs) runs on a spawn_blocking thread in a tight `loop { readline("> ") ... tx.send(line) }`. As soon as the line that typed "/edit" is handed to the main task via tx.send, the reader loop iterates and calls readline again — which paints "> " at the current cursor row — before the main task has even dispatched Command::Edit. Any subsequent completion / history / size-report repaint from rustyline lands on top of vim too. Add a Session::editor_lock tokio Mutex<()>. The reader loop takes it via blocking_lock() on each iteration and holds it across readline(); cmd_edit takes it with .lock().await before handing the terminal to the editor and drops it on return. That way the reader thread blocks inside blocking_lock() for the whole editor lifetime and never calls readline while the child owns the tty. The rustyline-init-failed fallback (read_stdin_plain) doesn't print a prompt, so it stays unchanged. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 20407ce..d5024db 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -228,6 +228,11 @@ pub struct Session { /// the child's cursor drift visibly. Set in cmd_edit before /// spawn, cleared after return. status_paused: Arc, + /// Held by cmd_edit while it has the terminal. The rustyline + /// reader loop (run() -> read_stdin) acquires it before each + /// readline() so it won't call readline at all — and redraw + /// the "> " prompt on top of vim — during /edit. + editor_lock: Arc>, /// §50: handles to every spawned MCP child process. On REPL /// exit we walk these and call `shutdown(2s)` on each so /// tracebacks flush cleanly instead of the child getting @@ -324,6 +329,7 @@ impl Session { any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), + editor_lock: Arc::new(tokio::sync::Mutex::new(())), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), }; } @@ -353,6 +359,7 @@ impl Session { any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), + editor_lock: Arc::new(tokio::sync::Mutex::new(())), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), } } @@ -444,7 +451,8 @@ impl Session { // on the retained outer-scope clone and ctrl-d appears to // hang the REPL. let (tx, mut rx) = mpsc::unbounded_channel::(); - tokio::task::spawn_blocking(move || read_stdin(tx)); + let reader_editor_lock = self.editor_lock.clone(); + tokio::task::spawn_blocking(move || read_stdin(tx, reader_editor_lock)); // Reserve the bottom two rows for a status bar + prompt. // Scrolling output stays above; status shows what each task @@ -1938,6 +1946,12 @@ impl Session { println!("/edit: create tempfile: {e}"); return; } + // Hold the editor lock across the whole spawn so the + // stdin-reader thread can't call rustyline::readline("> ") + // while the editor owns the terminal — otherwise the "> " + // prompt gets painted on top of vim's frame as soon as the + // previous line (the one that typed "/edit") is sent. + let _rl_guard = self.editor_lock.lock().await; // Tear down kres's DECSTBM scroll region (status.rs:50) and // clear the status row BEFORE handing the terminal to the // editor. Without this, vim/nvim paint into a terminal @@ -2873,7 +2887,10 @@ fn report_reaped(r: &kres_core::ReapedTask) { } } -fn read_stdin(tx: mpsc::UnboundedSender) { +fn read_stdin( + tx: mpsc::UnboundedSender, + editor_lock: Arc>, +) { // rustyline: line-editing + ^R history search + arrow-key recall. // History persists to $HOME/.kres/history. Falls back to plain // stdin on any rustyline init failure so a weird terminal doesn't @@ -2950,6 +2967,15 @@ fn read_stdin(tx: mpsc::UnboundedSender) { let _ = editor.load_history(p); } loop { + // Block while /edit holds the terminal. Without this, + // readline() would be re-called as soon as the previous + // line returned, and rustyline would paint "> " on top of + // vim's frame before the main loop has even dispatched + // Command::Edit. The lock is reacquired on every iteration + // so /edit only gates the NEXT readline, not an in-progress + // one. cmd_edit holds the lock across the editor spawn and + // releases it on return. + let _g = editor_lock.blocking_lock(); match editor.readline("> ") { Ok(line) => { if !line.trim().is_empty() { @@ -2967,6 +2993,7 @@ fn read_stdin(tx: mpsc::UnboundedSender) { Err(rustyline::error::ReadlineError::Eof) => break, Err(_) => break, } + drop(_g); } if let Some(ref p) = history_path { let _ = editor.save_history(p); From d2f06ff9a578569916571d0c2e2ccab611c69cb8 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:42:36 -0700 Subject: [PATCH 05/76] kres-repl: serialise readline against the main command loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt (b2c3476) put a Mutex around rustyline::readline: cmd_edit grabbed it before spawning vim, the reader thread held it across readline(). That deadlocked the operator — after typing "/edit" the reader re-acquired the mutex on the next iteration while cmd_edit was still awaiting its lock(), so the editor wouldn't launch until the operator hit Enter a second time to let the reader's in-flight readline() return. Replace the mutex with an ack channel. The reader now waits for an explicit ack from the main loop before printing each "> " prompt (after the first). The main loop sends the ack at the bottom of every iteration that consumed an rx.recv(); Quit skips the ack because it's about to tear the reader down anyway, and the auto-continue timer path doesn't ack because it wasn't servicing a user line. Effect: between tx.send("/edit") and the main loop finishing cmd_edit, the reader is parked in ack_rx.blocking_recv() and readline is not running — so rustyline can't paint "> " on top of the editor's frame. A single Enter fires /edit as intended. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 67 ++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index d5024db..b34bbd4 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -228,11 +228,14 @@ pub struct Session { /// the child's cursor drift visibly. Set in cmd_edit before /// spawn, cleared after return. status_paused: Arc, - /// Held by cmd_edit while it has the terminal. The rustyline - /// reader loop (run() -> read_stdin) acquires it before each - /// readline() so it won't call readline at all — and redraw - /// the "> " prompt on top of vim — during /edit. - editor_lock: Arc>, + /// The main loop sends on this after finishing each command; + /// the rustyline reader waits for the send before calling + /// readline() again (see read_stdin). That way `/edit` can + /// block in cmd_edit without the reader painting `"> "` on + /// top of vim in the meantime. Optional because Session::new + /// constructs a Session without a running reader; the channel + /// is installed in run() when the reader thread is spawned. + input_ack_tx: tokio::sync::Mutex>>, /// §50: handles to every spawned MCP child process. On REPL /// exit we walk these and call `shutdown(2s)` on each so /// tracebacks flush cleanly instead of the child getting @@ -329,7 +332,7 @@ impl Session { any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), - editor_lock: Arc::new(tokio::sync::Mutex::new(())), + input_ack_tx: tokio::sync::Mutex::new(None), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), }; } @@ -359,7 +362,7 @@ impl Session { any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), - editor_lock: Arc::new(tokio::sync::Mutex::new(())), + input_ack_tx: tokio::sync::Mutex::new(None), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), } } @@ -451,8 +454,14 @@ impl Session { // on the retained outer-scope clone and ctrl-d appears to // hang the REPL. let (tx, mut rx) = mpsc::unbounded_channel::(); - let reader_editor_lock = self.editor_lock.clone(); - tokio::task::spawn_blocking(move || read_stdin(tx, reader_editor_lock)); + // Ack channel: main loop sends after every command finishes, + // the reader waits for the ack before calling readline again. + // That keeps rustyline from painting "> " on top of a child + // process (vim) that cmd_edit is running, and keeps it from + // racing the main loop in general. + let (ack_tx, ack_rx) = mpsc::unbounded_channel::<()>(); + *self.input_ack_tx.lock().await = Some(ack_tx); + tokio::task::spawn_blocking(move || read_stdin(tx, ack_rx)); // Reserve the bottom two rows for a status bar + prompt. // Scrolling output stays above; status shows what each task @@ -1311,7 +1320,7 @@ impl Session { } }; match parse_command(&line) { - Command::Noop => continue, + Command::Noop => {} Command::Help => print_help(), Command::Tasks => self.print_tasks().await, Command::Stop => self.cmd_stop().await, @@ -1393,6 +1402,13 @@ impl Session { } } } + // Command done. Tell the stdin reader it may call + // readline again and paint the next "> " prompt. Skipped + // on Quit (that branch `return`s above, dropping the + // reader's channel). + if let Some(tx) = self.input_ack_tx.lock().await.as_ref() { + let _ = tx.send(()); + } } // --turns exit path: reaper flips turns_exhausted when the @@ -1946,12 +1962,6 @@ impl Session { println!("/edit: create tempfile: {e}"); return; } - // Hold the editor lock across the whole spawn so the - // stdin-reader thread can't call rustyline::readline("> ") - // while the editor owns the terminal — otherwise the "> " - // prompt gets painted on top of vim's frame as soon as the - // previous line (the one that typed "/edit") is sent. - let _rl_guard = self.editor_lock.lock().await; // Tear down kres's DECSTBM scroll region (status.rs:50) and // clear the status row BEFORE handing the terminal to the // editor. Without this, vim/nvim paint into a terminal @@ -2889,7 +2899,7 @@ fn report_reaped(r: &kres_core::ReapedTask) { fn read_stdin( tx: mpsc::UnboundedSender, - editor_lock: Arc>, + mut ack_rx: mpsc::UnboundedReceiver<()>, ) { // rustyline: line-editing + ^R history search + arrow-key recall. // History persists to $HOME/.kres/history. Falls back to plain @@ -2966,16 +2976,20 @@ fn read_stdin( } let _ = editor.load_history(p); } + let mut first_prompt = true; loop { - // Block while /edit holds the terminal. Without this, - // readline() would be re-called as soon as the previous - // line returned, and rustyline would paint "> " on top of - // vim's frame before the main loop has even dispatched - // Command::Edit. The lock is reacquired on every iteration - // so /edit only gates the NEXT readline, not an in-progress - // one. cmd_edit holds the lock across the editor spawn and - // releases it on return. - let _g = editor_lock.blocking_lock(); + // After the first line, wait for the main loop to + // ack-complete the previous command before printing the + // next "> " prompt. Without this, readline() fires again + // the moment tx.send returns, and rustyline paints the + // prompt on top of vim's frame as soon as "/edit" is + // sent — well before cmd_edit has had a chance to take + // over the terminal. On None (channel closed) we break + // out; the REPL is tearing down. + if !first_prompt && ack_rx.blocking_recv().is_none() { + break; + } + first_prompt = false; match editor.readline("> ") { Ok(line) => { if !line.trim().is_empty() { @@ -2993,7 +3007,6 @@ fn read_stdin( Err(rustyline::error::ReadlineError::Eof) => break, Err(_) => break, } - drop(_g); } if let Some(ref p) = history_path { let _ = editor.save_history(p); From 577d8afc55eac696c1c3d7f316df99dd7065b91a Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:47:05 -0700 Subject: [PATCH 06/76] kres-repl: skip reaper inference chain while /stop is latched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /stop cancels in-flight tasks and drains the todo list, but the reaper was still running its per-reap pipeline against the cancelled tasks: findings merger, goal check, goal-not-met missing-item injection, and the post-task todo-agent update. Every one of those is an API call, and the missing-item injection writes into the todo list the operator just emptied — so seconds after typing /stop the queue is back full of new work, the auto-continue latch is the only thing stopping the REPL from redispatching, and the operator sees "it's still going". Gate the reaper's post-append work on stop_latched. After the task's analysis is written to accumulated and report.md (and any coding-mode files are persisted under /code/), the reaper checks the latch and `continue`s past the merger / goal / todo-agent block. A reap that was already in an inference call finishes that call before returning, but no new calls start, and no new items land in the todo queue. The --turns 0 stop block and all earlier parts of the reap iteration (report append, code_output persist) run as before so legitimate history from tasks that completed before /stop is still captured. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index b34bbd4..422f994 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -617,6 +617,7 @@ impl Session { // ~/.kres/sessions//code/hello-world.c. let code_output_root_for_reaper: PathBuf = self.cfg.workspace.clone(); let turns_exhausted_for_reaper = self.turns_exhausted.clone(); + let stop_latched_for_reaper = self.stop_latched.clone(); let turns_limit = self.cfg.turns_limit; let follow_followups = self.cfg.follow_followups; // §16: findings-signature watchdog. Every successful merge @@ -758,6 +759,20 @@ impl Session { .await; } let pre_size = mgr_for_reaper.findings_snapshot().await.len(); + // /stop is latched: skip every inference-heavy + // reaper post-step (findings merger, goal check, + // todo-agent update). The cancelled task is + // already reaped; report.md + accumulated + // already captured whatever prose survived. + // Continuing through merger/goal/todo-update + // would rack up API calls AND inject new todos + // into the queue the operator just drained with + // /stop, reproducing the "still going" feeling. + let stop_latched_now = stop_latched_for_reaper + .load(std::sync::atomic::Ordering::Acquire); + if stop_latched_now { + continue; + } // Findings merger runs for both Analysis (review) // and Generic tasks — both feed the findings // pipeline. Coding tasks skip it: their output is From 8163c85f71d7092c9a0ee7452542e34787b1b07c Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 16:53:42 -0700 Subject: [PATCH 07/76] slow-code-agent-coding: forbid patch-file artifacts for fix tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session a85dbc41 (2026-04-21): operator asked "read report.md, identify the first bug reported, code a fix". The pipeline loaded drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c as a 13 KB inline symbol and the slow-coding agent emitted code_output[0] = {path: "fix-bnxt-xdp-redirect-frag-leak.patch", content: "From: … diff --git a/drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c … @@ -297,6 +297,7 @@ …"}. The artifact sat next to the unchanged source; the operator had to manually verify every line number and hunk context against the real tree before it was usable. Add a FIXES AND PATCHES block to the slow-coding system prompt: - fix tasks (the word "fix", "patch", "apply", etc. in the task) require the verbatim current file contents in symbols/context before any output is produced. If the excerpt the agent was given doesn't cover the exact line being changed, the agent must request a `read` followup and WAIT for the next turn rather than reconstruct from memory. - code_output is never allowed to be a standalone .patch / .diff file. The repair goes either through an edit-tool followup (when available) or as code_output whose `path` IS the file being fixed and whose `content` is the full post-fix body copied from the input. - Line numbers and surrounding context in the output must match the file on disk exactly; reconstruction from a summary is explicitly called out as a bug, with the session id as the anchor. Signed-off-by: Chris Mason --- .../prompts/slow-code-agent-coding.system.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/configs/prompts/slow-code-agent-coding.system.md b/configs/prompts/slow-code-agent-coding.system.md index 7e399f4..c766561 100644 --- a/configs/prompts/slow-code-agent-coding.system.md +++ b/configs/prompts/slow-code-agent-coding.system.md @@ -6,6 +6,39 @@ SCOPE CHECK — do this BEFORE writing code: - Re-read 'question'. It carries the Original user prompt and usually a narrower Current task. You are responsible for the whole original-prompt scope. - Do you have every file, struct, API, and config knob you need to write a self-contained artifact? If a needed header, kernel selftest helper, userspace library entry point, or related function body is NOT in symbols/context, emit a followup for it. State in 'analysis' which parts of the artifact are blocked on missing input. - Do not invent APIs you did not see in the gathered context. If you need `bpf(2)`, `io_uring_setup`, a specific ioctl, etc., require the prototype or header snippet in the gathered data. Name the missing piece in a followup. + +FIXES AND PATCHES — do NOT code from memory: +- When the task is to FIX existing code ("code a fix", "apply a + patch", "fix the bug in X", "update Y to handle Z"), the fix + MUST be expressed as an edit to a file that already exists on + disk. It is never acceptable to generate a fix from training + memory or from summary-level descriptions in a report. +- Before emitting any fix, the VERBATIM current contents of the + file (or at minimum the exact function / hunk being changed) + MUST be in 'symbols' or 'context'. If they are not — or if the + content you were given is an excerpt that doesn't include the + line you want to change — request a `read` followup for the + exact range and WAIT for the next turn. Do not guess, do not + reconstruct, do not emit a fix built from "what the file + probably looks like". +- Do NOT emit unified diffs or `.patch` files as code_output. The + consumer treats code_output as "write this file's contents to + disk" — a patch file written alongside the real source is not a + fix, it's a TODO that the operator still has to apply. Instead: + - preferred (when available): request the fix be applied via the + edit-tool followup (see FOLLOWUPS below) so the repair goes + directly into the source file on disk; + - fallback: emit code_output whose `path` IS the file being + fixed (e.g. `drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c`) + and whose `content` is the full post-fix file body, copied + from what you were given with the fix applied in place. You + must have the entire file in your inputs before doing this; + do not truncate or ellide. +- Line numbers and surrounding context in your output must match + the file on disk exactly. Session a85dbc41 (2026-04-21) produced + a .patch file whose hunk was reconstructed from a 13 KB inline + copy of the source; the operator then had to verify it manually + against the real tree. Don't do that again. - You MAY ask the pipeline to build or run what you wrote. Emit a `bash` followup (see FOLLOWUPS below) with a short `command` like `cc -o repro repro.c && ./repro` or `make -C test && ./test/run`. From 68fc5a9e310197dfaba5faf582803cde9e6b8d39 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 17:03:59 -0700 Subject: [PATCH 08/76] tools: add Claude-Code-style edit primitive for in-place fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coding-mode fixes have had no way to land surgically on a file. code_output writes (or rewrites) a whole file, which is fine for a fresh reproducer but a bad fit for a one-line fix: the model has to re-emit the entire target file from memory, burning tokens and opening the door to transcription errors. Session a85dbc41 (2026-04-21) fell off this edge and produced a .patch file with hand-reconstructed hunk context. Add an `edit` tool that matches Claude Code's Edit primitive: {type: "edit", file_path, old_string, new_string, replace_all} old_string is looked up literally in the current file contents and must appear exactly once unless replace_all is true. The file is rewritten via tmp + rename for crash safety. Field names match Claude Code (file_path, old_string, new_string, replace_all) on purpose — models already know that shape, and the `old_string` anchor forces them to quote bytes from the real file rather than reconstruct them. `path` and `file` are accepted aliases for `file_path`. Plumbing: - kres-agents/src/tools.rs: EditArgs + edit_file. Rejects empty old_string, old_string == new_string, zero or ambiguous matches, and workspace-escape paths. Returns a short preview of 5 lines centred on the first replacement so the fast agent has cheap evidence. - kres-agents/src/main_agent.rs: `"edit" =>` branch in dispatch_non_mcp and action_label. - kres-core mode.rs: CodeEdit lives here next to CodeFile so TaskOutcome and ReapedTask can carry it without depending on kres-agents. kres-agents re-exports it under the old name. - kres-agents response.rs + pipeline.rs + reaper: code_edits flows from the slow-coding reply through TaskSummary and TaskOutcome; the reaper calls a new apply_code_edits that loops edit_file over each entry, logging per-edit results. - completed_run_count now counts a coding task as produced when code_edits is non-empty even if code_output and analysis are empty. Prompts: - main-agent.system.md documents the "edit" action. - slow-code-agent-coding.system.md FIXES-AND-PATCHES now points at code_edits as the primary surgical-fix channel and keeps code_output full-file rewrite as a fallback. Tests: six new tokio tests cover unique-match / missing / ambiguous / replace-all / traversal / empty-and-identity. Full workspace test run is clean across all 11 bins. Signed-off-by: Chris Mason --- configs/prompts/main-agent.system.md | 8 + .../prompts/slow-code-agent-coding.system.md | 29 +- kres-agents/src/lib.rs | 2 +- kres-agents/src/main_agent.rs | 49 ++- kres-agents/src/pipeline.rs | 18 +- kres-agents/src/response.rs | 27 ++ kres-agents/src/tools.rs | 283 +++++++++++++++++- kres-core/src/lib.rs | 2 +- kres-core/src/mode.rs | 15 + kres-core/src/task.rs | 15 +- kres-repl/src/session.rs | 49 +++ 11 files changed, 472 insertions(+), 25 deletions(-) diff --git a/configs/prompts/main-agent.system.md b/configs/prompts/main-agent.system.md index 80c547d..ca3eb07 100644 --- a/configs/prompts/main-agent.system.md +++ b/configs/prompts/main-agent.system.md @@ -13,6 +13,14 @@ Map each followup type to a tool: they wrote. `--amend`, `--no-verify`, `--no-gpg-sign` are rejected; `push`/`pull`/`fetch` are absent on purpose (the tool is workspace-local). +- "edit" → surgical string-replacement edit to an existing file. + Use {"type": "edit", "file_path": "rel/path.c", "old_string": "...", + "new_string": "...", "replace_all": false}. Same shape and + semantics as Claude Code's Edit primitive: `old_string` is looked + up literally; it must appear exactly once unless `replace_all` is + true. Writes via tmp+rename for crash safety. Aliases accepted: + `path` / `file` for `file_path`. Mainly used by the coding flow + to apply fixes in-place. - "bash" → run `bash -c ` from the workspace root. Use {"type": "bash", "command": "cc -o hw hw.c && ./hw", "timeout_secs": 60, "cwd": "subdir"}. `command` is mandatory; `cmd` and `name` are accepted aliases so diff --git a/configs/prompts/slow-code-agent-coding.system.md b/configs/prompts/slow-code-agent-coding.system.md index c766561..15fd2de 100644 --- a/configs/prompts/slow-code-agent-coding.system.md +++ b/configs/prompts/slow-code-agent-coding.system.md @@ -25,15 +25,22 @@ FIXES AND PATCHES — do NOT code from memory: consumer treats code_output as "write this file's contents to disk" — a patch file written alongside the real source is not a fix, it's a TODO that the operator still has to apply. Instead: - - preferred (when available): request the fix be applied via the - edit-tool followup (see FOLLOWUPS below) so the repair goes - directly into the source file on disk; - - fallback: emit code_output whose `path` IS the file being - fixed (e.g. `drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c`) - and whose `content` is the full post-fix file body, copied - from what you were given with the fix applied in place. You - must have the entire file in your inputs before doing this; - do not truncate or ellide. + - preferred for small surgical fixes: emit entries in the + `code_edits` array. Each entry is + `{file_path, old_string, new_string, replace_all?}` and shapes + exactly like Claude Code's Edit primitive: `old_string` is + looked up literally in the current file contents and must + appear exactly once (set `replace_all: true` to allow + multiple). The reaper applies each edit via the in-tree edit + tool, atomic tmp + rename. This is the best fit for adding a + missing `bnxt_xdp_buff_frags_free(rxr, xdp);` line or similar; + - fallback for large-scale rewrites: emit code_output whose + `path` IS the file being fixed (e.g. + `drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c`) and whose + `content` is the full post-fix file body, copied from what + you were given with the fix applied in place. You must have + the entire file in your inputs before doing this; do not + truncate or ellide. - Line numbers and surrounding context in your output must match the file on disk exactly. Session a85dbc41 (2026-04-21) produced a .patch file whose hunk was reconstructed from a 13 KB inline @@ -51,7 +58,9 @@ FIXES AND PATCHES — do NOT code from memory: you are confident are safe in the operator's workspace. Output: JSON only, no fences, no preamble. -{"analysis": "prose commentary with inline code snippets", "code_output": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}]} +{"analysis": "prose commentary with inline code snippets", "code_output": [, ...], "code_edits": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}]} + +CodeEdit shape (same as Claude Code's Edit): `{file_path, old_string, new_string, replace_all?}`. Leave `replace_all` off (defaults to false) and `old_string` must match exactly once. `old_string` and `new_string` are VERBATIM byte sequences; include enough surrounding context to make `old_string` unique in the file. CODE_OUTPUT — primary artifact: - 'code_output' is an array of {path, content, purpose} records. EACH file you produce is one entry. Use forward-slash relative paths; they land under `/code/` on disk. diff --git a/kres-agents/src/lib.rs b/kres-agents/src/lib.rs index a4f024e..7dad1d1 100644 --- a/kres-agents/src/lib.rs +++ b/kres-agents/src/lib.rs @@ -40,7 +40,7 @@ pub use pipeline::{ TaskSummary, }; pub use prompt_file::{parse as parse_prompt_file, PromptFile}; -pub use response::{parse_code_response, CodeResponse}; +pub use response::{parse_code_response, CodeEdit, CodeResponse}; pub use skills::{InvocationPolicy, Skill, Skills}; pub use symbol::{ append_context, append_symbol, ctx_identity, parse_semcode_symbol, previously_fetched_manifest, diff --git a/kres-agents/src/main_agent.rs b/kres-agents/src/main_agent.rs index 46356a8..afb9bee 100644 --- a/kres-agents/src/main_agent.rs +++ b/kres-agents/src/main_agent.rs @@ -41,8 +41,8 @@ use crate::{ append_context, append_symbol, parse_semcode_symbol, propagate_tool_result, tool_source, }, tools::{ - bash_run, find, git, grep, read_file_range, truncate_output, BashArgs, FindArgs, GitArgs, - GrepArgs, ReadArgs, TOOL_OUTPUT_CAP_MCP, + bash_run, edit_file, find, git, grep, read_file_range, truncate_output, BashArgs, EditArgs, + FindArgs, GitArgs, GrepArgs, ReadArgs, TOOL_OUTPUT_CAP_MCP, }, }; @@ -537,6 +537,42 @@ async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (Strin Err(e) => (format!("[error] {e}"), None), } } + "edit" => { + // Accept Claude-Code-style `file_path` + `old_string` + + // `new_string`; allow `path` and `file` as aliases for + // the path so follow-up-shape requests work. + let file_path = action + .get("file_path") + .or_else(|| action.get("path")) + .or_else(|| action.get("file")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let old_string = action + .get("old_string") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let new_string = action + .get("new_string") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let replace_all = action + .get("replace_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let args = EditArgs { + file_path, + old_string, + new_string, + replace_all, + }; + match edit_file(workspace, &args).await { + Ok(t) => (t, None), + Err(e) => (format!("[error] {e}"), None), + } + } "bash" => { // Accept `command`, `cmd`, and `name` — `name` is what // the slow/fast agents emit when a bash call comes in as @@ -617,6 +653,15 @@ fn action_label(action: &Value) -> String { .and_then(|v| v.as_str()) .unwrap_or("?") ), + "edit" => format!( + "edit {}", + action + .get("file_path") + .or_else(|| action.get("path")) + .or_else(|| action.get("file")) + .and_then(|v| v.as_str()) + .unwrap_or("?") + ), other => other.to_string(), } } diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index 405bf3a..03d5d52 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -214,6 +214,9 @@ pub struct TaskSummary { /// Source files emitted by a Coding-mode task. Empty for /// Analysis-mode tasks. pub code_output: Vec, + /// String-replacement edits emitted by a Coding-mode task. + /// The reaper applies each entry via tools::edit_file. + pub code_edits: Vec, } impl Orchestrator { @@ -614,12 +617,17 @@ impl Orchestrator { // coding task is not supposed to participate in the findings // pipeline (the reaper will skip merge/consolidator on this // mode). Analysis and Generic tasks keep the historical - // shape (findings go through the merger). - let (findings_out, code_output) = match ctx.mode { + // shape (findings go through the merger) and do not emit + // in-place edits — edits only flow from coding mode. + let (findings_out, code_output, code_edits) = match ctx.mode { kres_core::TaskMode::Analysis | kres_core::TaskMode::Generic => { - (slow_parsed.findings, Vec::new()) + (slow_parsed.findings, Vec::new(), Vec::new()) } - kres_core::TaskMode::Coding => (Vec::new(), slow_parsed.code_output), + kres_core::TaskMode::Coding => ( + Vec::new(), + slow_parsed.code_output, + slow_parsed.code_edits, + ), }; Ok(TaskSummary { analysis: slow_parsed.analysis, @@ -629,6 +637,7 @@ impl Orchestrator { strategy: slow_parsed.strategy, mode: ctx.mode, code_output, + code_edits, }) } } @@ -797,6 +806,7 @@ impl Orchestrator { strategy: ParseStrategy::WholeBody, mode: kres_core::TaskMode::Analysis, code_output: Vec::new(), + code_edits: Vec::new(), }) } diff --git a/kres-agents/src/response.rs b/kres-agents/src/response.rs index d715883..0a43e33 100644 --- a/kres-agents/src/response.rs +++ b/kres-agents/src/response.rs @@ -42,10 +42,21 @@ pub struct CodeResponse { /// `{"analysis": "...", "code_output": [{path, content, purpose}], "followups": [...]}` /// and this field is populated from that `code_output` array. pub code_output: Vec, + /// Surgical string-replacement edits to existing files, the + /// coding-mode equivalent of code_output but for FIXES rather + /// than new artifacts. Shape mirrors Claude Code's Edit + /// primitive: `{file_path, old_string, new_string, replace_all}`. + /// The reaper applies each entry via `tools::edit_file`. + pub code_edits: Vec, /// Which parse strategy won — used for diagnostics. pub strategy: ParseStrategy, } +/// Re-export of kres_core::CodeEdit so older callers that import +/// `kres_agents::CodeEdit` continue to compile. The canonical type +/// lives in kres-core so TaskOutcome can carry it. +pub use kres_core::CodeEdit; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ParseStrategy { #[default] @@ -71,6 +82,8 @@ struct RawResponse { ready_for_slow: Value, #[serde(default)] code_output: Value, + #[serde(default)] + code_edits: Value, } pub fn parse_code_response(text: &str) -> CodeResponse { @@ -131,6 +144,7 @@ pub fn parse_code_response(text: &str) -> CodeResponse { findings: vec![], ready_for_slow: false, code_output: vec![], + code_edits: vec![], strategy: ParseStrategy::RawText, } } @@ -148,6 +162,7 @@ fn raw_has_content(r: &RawResponse) -> bool { || list_nonempty(&r.findings) || list_nonempty(&r.skill_reads) || list_nonempty(&r.code_output) + || list_nonempty(&r.code_edits) || bool_true } @@ -174,10 +189,22 @@ fn into_code_response(r: RawResponse, _original: &str, strategy: ParseStrategy) findings: value_to_findings(r.findings), ready_for_slow: matches!(r.ready_for_slow, Value::Bool(true)), code_output: value_to_code_output(r.code_output), + code_edits: value_to_code_edits(r.code_edits), strategy, } } +fn value_to_code_edits(v: Value) -> Vec { + let Value::Array(items) = v else { + return vec![]; + }; + items + .into_iter() + .filter_map(|i| serde_json::from_value::(i).ok()) + .filter(|e| !e.file_path.is_empty() && !e.old_string.is_empty()) + .collect() +} + fn value_to_code_output(v: Value) -> Vec { let Value::Array(items) = v else { return vec![]; diff --git a/kres-agents/src/tools.rs b/kres-agents/src/tools.rs index f4e1e9f..a1d3255 100644 --- a/kres-agents/src/tools.rs +++ b/kres-agents/src/tools.rs @@ -1,11 +1,14 @@ //! Internal tool implementations for the main-agent data path. //! -//! Four low-dependency tools: `read` (file range), `grep` (regex -//! over a path), `git` (readonly whitelisted commands), and `bash` -//! (arbitrary shell command, scoped to the workspace, mainly used by -//! the coding flow to compile and run generated source). MCP tools -//! route through a separate adapter in kres-repl; keeping those -//! out of kres-agents avoids a transitive kres-mcp dependency here. +//! Five low-dependency tools: `read` (file range), `grep` (regex +//! over a path), `git` (readonly whitelisted commands), `bash` +//! (arbitrary shell command, scoped to the workspace, mainly used +//! by the coding flow to compile and run generated source), and +//! `edit` (string-replacement edit to an existing file, matching +//! Claude Code's Edit primitive — `old_string` must appear exactly +//! once unless `replace_all=true`). MCP tools route through a +//! separate adapter in kres-repl; keeping those out of kres-agents +//! avoids a transitive kres-mcp dependency here. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -348,6 +351,152 @@ pub async fn bash_run(workspace: &Path, args: &BashArgs) -> Result Result { + if args.old_string.is_empty() { + return Err(AgentError::Other( + "edit: old_string is empty — refusing to insert new_string at position 0; use a read+full-file-rewrite instead".into(), + )); + } + if args.old_string == args.new_string { + return Err(AgentError::Other( + "edit: old_string == new_string — nothing to do".into(), + )); + } + let abs = resolve_workspace(workspace, &args.file_path)?; + let original = tokio::fs::read_to_string(&abs).await.map_err(|e| { + AgentError::Other(format!("edit: read {}: {e}", abs.display())) + })?; + let count = count_occurrences(&original, &args.old_string); + if count == 0 { + return Err(AgentError::Other(format!( + "edit: old_string not found in {} — re-read the file and supply bytes copied verbatim from the current contents", + abs.display() + ))); + } + let replacements; + let updated = if args.replace_all { + replacements = count; + original.replace(&args.old_string, &args.new_string) + } else if count > 1 { + return Err(AgentError::Other(format!( + "edit: old_string matches {count} locations in {} — narrow it (include more surrounding context) or pass replace_all=true", + abs.display() + ))); + } else { + replacements = 1; + original.replacen(&args.old_string, &args.new_string, 1) + }; + // Atomic write: tmp + fsync + rename. Keeps the file either + // fully-pre-edit or fully-post-edit on crash. + let tmp = abs.with_extension(format!( + "{}.kres-edit.tmp", + abs.extension().and_then(|e| e.to_str()).unwrap_or("") + )); + { + use tokio::io::AsyncWriteExt as _; + let mut f = tokio::fs::File::create(&tmp).await.map_err(|e| { + AgentError::Other(format!("edit: create {}: {e}", tmp.display())) + })?; + f.write_all(updated.as_bytes()).await.map_err(|e| { + AgentError::Other(format!("edit: write {}: {e}", tmp.display())) + })?; + f.sync_all().await.map_err(|e| { + AgentError::Other(format!("edit: fsync {}: {e}", tmp.display())) + })?; + } + tokio::fs::rename(&tmp, &abs).await.map_err(|e| { + AgentError::Other(format!( + "edit: rename {} -> {}: {e}", + tmp.display(), + abs.display() + )) + })?; + // Build the preview — EDIT_PREVIEW_LINES lines of context + // centred on the first replacement site. Gives the fast agent + // a cheap sanity-check it can relay to the slow agent without + // re-reading the whole file. + let preview = build_edit_preview(&updated, &args.new_string, EDIT_PREVIEW_LINES); + Ok(format!( + "[edit {}] {replacements} replacement(s) (before: {}c, after: {}c)\n{preview}", + abs.display(), + original.len(), + updated.len() + )) +} + +fn count_occurrences(haystack: &str, needle: &str) -> usize { + if needle.is_empty() { + return 0; + } + let mut n = 0usize; + let mut start = 0usize; + while let Some(off) = haystack[start..].find(needle) { + n += 1; + start += off + needle.len(); + } + n +} + +fn build_edit_preview(body: &str, new_string: &str, window_lines: usize) -> String { + let Some(pos) = body.find(new_string) else { + return String::new(); + }; + let line_of_pos = body[..pos].matches('\n').count(); // 0-indexed + let all_lines: Vec<&str> = body.split_inclusive('\n').collect(); + let first = line_of_pos.saturating_sub(window_lines / 2); + let last = (line_of_pos + window_lines / 2 + 1).min(all_lines.len()); + let mut out = String::from("preview:\n"); + for (i, line) in all_lines[first..last].iter().enumerate() { + out.push_str(&format!( + "{:>6}: {}", + first + i + 1, + if line.ends_with('\n') { + line.to_string() + } else { + format!("{line}\n") + } + )); + } + out +} + pub async fn git(workspace: &Path, args: &GitArgs) -> Result { let parts = shell_split(&args.command) .ok_or_else(|| AgentError::Other(format!("unparseable git command: {}", args.command)))?; @@ -612,6 +761,128 @@ mod tests { p } + #[tokio::test] + async fn edit_replaces_unique_old_string() { + let dir = tmpdir("edit-unique"); + let path = dir.join("foo.c"); + std::fs::write(&path, "line one\nhello world\nline three\n").unwrap(); + let args = EditArgs { + file_path: "foo.c".into(), + old_string: "hello world".into(), + new_string: "hola mundo".into(), + replace_all: false, + }; + let msg = edit_file(&dir, &args).await.unwrap(); + assert!(msg.starts_with("[edit "), "got {msg}"); + assert!(msg.contains("1 replacement(s)"), "got {msg}"); + let updated = std::fs::read_to_string(&path).unwrap(); + assert_eq!(updated, "line one\nhola mundo\nline three\n"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn edit_rejects_missing_old_string() { + let dir = tmpdir("edit-missing"); + let path = dir.join("foo.c"); + std::fs::write(&path, "line one\nhello\n").unwrap(); + let args = EditArgs { + file_path: "foo.c".into(), + old_string: "not present".into(), + new_string: "xxx".into(), + replace_all: false, + }; + let res = edit_file(&dir, &args).await; + match res { + Err(AgentError::Other(m)) => { + assert!(m.contains("old_string not found"), "got {m}"); + assert!(m.contains("re-read"), "got {m}"); + } + _ => panic!("expected not-found error, got {res:?}"), + } + // File untouched. + assert_eq!(std::fs::read_to_string(&path).unwrap(), "line one\nhello\n"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn edit_rejects_ambiguous_without_replace_all() { + let dir = tmpdir("edit-ambig"); + let path = dir.join("foo.c"); + std::fs::write(&path, "x\nx\nx\n").unwrap(); + let args = EditArgs { + file_path: "foo.c".into(), + old_string: "x".into(), + new_string: "y".into(), + replace_all: false, + }; + let res = edit_file(&dir, &args).await; + match res { + Err(AgentError::Other(m)) => { + assert!(m.contains("matches 3 locations"), "got {m}"); + assert!(m.contains("replace_all=true"), "got {m}"); + } + _ => panic!("expected ambiguity error, got {res:?}"), + } + // File untouched. + assert_eq!(std::fs::read_to_string(&path).unwrap(), "x\nx\nx\n"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn edit_replace_all_flips_every_match() { + let dir = tmpdir("edit-all"); + let path = dir.join("foo.c"); + std::fs::write(&path, "x\nx\nx\n").unwrap(); + let args = EditArgs { + file_path: "foo.c".into(), + old_string: "x".into(), + new_string: "y".into(), + replace_all: true, + }; + let msg = edit_file(&dir, &args).await.unwrap(); + assert!(msg.contains("3 replacement(s)"), "got {msg}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "y\ny\ny\n"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn edit_rejects_traversal() { + let dir = tmpdir("edit-traversal"); + let args = EditArgs { + file_path: "../escape.c".into(), + old_string: "anything".into(), + new_string: "other".into(), + replace_all: false, + }; + let res = edit_file(&dir, &args).await; + assert!(matches!(res, Err(AgentError::Other(_)))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn edit_rejects_empty_old_and_identity() { + let dir = tmpdir("edit-empty"); + let path = dir.join("foo.c"); + std::fs::write(&path, "some body\n").unwrap(); + let empty_old = EditArgs { + file_path: "foo.c".into(), + old_string: String::new(), + new_string: "x".into(), + replace_all: false, + }; + assert!(matches!(edit_file(&dir, &empty_old).await, Err(_))); + let identity = EditArgs { + file_path: "foo.c".into(), + old_string: "some body".into(), + new_string: "some body".into(), + replace_all: false, + }; + assert!(matches!(edit_file(&dir, &identity).await, Err(_))); + // File untouched. + assert_eq!(std::fs::read_to_string(&path).unwrap(), "some body\n"); + std::fs::remove_dir_all(&dir).ok(); + } + #[tokio::test] async fn bash_captures_stdout_stderr_and_exit() { let dir = tmpdir("bash1"); diff --git a/kres-core/src/lib.rs b/kres-core/src/lib.rs index 1a0c760..dd20658 100644 --- a/kres-core/src/lib.rs +++ b/kres-core/src/lib.rs @@ -31,7 +31,7 @@ pub use cost::{UsageEntry, UsageKey, UsageTracker}; pub use findings::{Finding, FindingsFile, FindingsStore, Severity}; pub use lens::LensSpec; pub use log::{LoggedUsage, TurnLogger}; -pub use mode::{CodeFile, TaskMode}; +pub use mode::{CodeEdit, CodeFile, TaskMode}; pub use shrink::{ estimate_tokens, finding_char_size, fit_payload, shrink_findings_to_budget, shrink_last_user_message, total_char_size, diff --git a/kres-core/src/mode.rs b/kres-core/src/mode.rs index 04ce7ab..b787d80 100644 --- a/kres-core/src/mode.rs +++ b/kres-core/src/mode.rs @@ -44,6 +44,21 @@ pub struct CodeFile { pub purpose: String, } +/// One string-replacement edit emitted by a coding-mode slow-agent +/// turn. Shape mirrors Claude Code's Edit primitive; `file_path` is +/// resolved via the workspace / consent path the same way `read` +/// and `edit` actions are. Reaper applies each entry via +/// `kres_agents::tools::edit_file`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CodeEdit { + #[serde(alias = "path")] + pub file_path: String, + pub old_string: String, + pub new_string: String, + #[serde(default)] + pub replace_all: bool, +} + impl Default for TaskMode { fn default() -> Self { Self::Analysis diff --git a/kres-core/src/task.rs b/kres-core/src/task.rs index cf7b3e8..a369f18 100644 --- a/kres-core/src/task.rs +++ b/kres-core/src/task.rs @@ -81,6 +81,9 @@ struct TaskEntry { /// Code files the task produced. Only ever populated for /// Coding-mode tasks. code_output: Vec, + /// String-replacement edits the task emitted. Only ever + /// populated for Coding-mode tasks. + code_edits: Vec, handle: Option>, /// Gets notified when the task transitions into a terminal state. done_notify: Arc, @@ -289,6 +292,7 @@ impl TaskManager { analysis: String::new(), mode: crate::TaskMode::default(), code_output: Vec::new(), + code_edits: Vec::new(), handle: Some(handle), done_notify, }); @@ -314,12 +318,15 @@ impl TaskManager { entry.followups = outcome.followups; entry.mode = outcome.mode; entry.code_output = outcome.code_output; + entry.code_edits = outcome.code_edits; // Per bugs.md#H4: only count tasks that actually produced // analysis and did not error. Coding-mode tasks count // against --turns N the same way analysis tasks do: they // consumed a slow-agent call, which is what the cap is // meant to bound. - let produced = !entry.analysis.is_empty() || !entry.code_output.is_empty(); + let produced = !entry.analysis.is_empty() + || !entry.code_output.is_empty() + || !entry.code_edits.is_empty(); if produced { g.completed_run_count = g.completed_run_count.saturating_add(1); } @@ -438,6 +445,7 @@ impl TaskManager { followups: entry.followups, mode: entry.mode, code_output: entry.code_output, + code_edits: entry.code_edits, }); } else { keep.push(entry); @@ -576,6 +584,8 @@ pub struct ReapedTask { pub mode: crate::TaskMode, /// Code files emitted by a Coding-mode task. pub code_output: Vec, + /// String-replacement edits emitted by a Coding-mode task. + pub code_edits: Vec, } #[derive(Debug, Clone, Default)] @@ -595,6 +605,9 @@ pub struct TaskOutcome { /// Analysis-mode tasks. The reaper writes each entry under /// `/code/`. pub code_output: Vec, + /// Surgical edits produced by a Coding-mode task. The reaper + /// applies each entry via kres_agents::tools::edit_file. + pub code_edits: Vec, } /// Handed to a task's work closure. Provides cancellation and access diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 422f994..f30c57c 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -758,6 +758,16 @@ impl Session { ) .await; } + if matches!(r.mode, kres_core::TaskMode::Coding) + && !r.code_edits.is_empty() + { + apply_code_edits( + &code_output_root_for_reaper, + &r.name, + &r.code_edits, + ) + .await; + } let pre_size = mgr_for_reaper.findings_snapshot().await.len(); // /stop is latched: skip every inference-heavy // reaper post-step (findings merger, goal check, @@ -1724,6 +1734,7 @@ impl Session { .collect(), mode: summary.mode, code_output: summary.code_output, + code_edits: summary.code_edits, }) } Err(e) => Err(e.to_string()), @@ -2799,6 +2810,44 @@ pub async fn build_orchestrator( /// segments (`..`) so a malformed model reply can't drop files /// outside the workspace root. Each file is written with a /// tmp + rename so a crash doesn't leave a partial artifact. +/// Apply each CodeEdit emitted by a coding-mode task to its target +/// file on disk via kres_agents::tools::edit_file. Logs one line +/// per edit with replacement count + before/after sizes; errors +/// are logged but don't abort the batch — other edits still run. +async fn apply_code_edits( + workspace: &Path, + task_name: &str, + edits: &[kres_core::CodeEdit], +) { + let mut applied = 0usize; + let mut failed = 0usize; + for e in edits { + let args = kres_agents::tools::EditArgs { + file_path: e.file_path.clone(), + old_string: e.old_string.clone(), + new_string: e.new_string.clone(), + replace_all: e.replace_all, + }; + match kres_agents::tools::edit_file(workspace, &args).await { + Ok(msg) => { + applied += 1; + kres_core::async_eprintln!("[coding-edit] {msg}"); + } + Err(err) => { + failed += 1; + kres_core::async_eprintln!( + "[coding-edit] {}: {err}", + e.file_path + ); + } + } + } + kres_core::async_eprintln!( + "[coding-edit] {task_name}: applied {applied}/{} edit(s) ({failed} failed)", + edits.len() + ); +} + async fn persist_code_output( workspace: &Path, task_name: &str, From 6369fa5c9bc0a86f7bb778a649780f7ff77f2194 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 17:06:31 -0700 Subject: [PATCH 09/76] main-agent: document the read tool's line-range args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read tool already supports line/end_line/count (aliases startLine/endLine; see kres-agents/src/tools.rs ReadArgs), but the main-agent system prompt only listed `"read" → read` — no shape, no example. The model reached for `bash sed -n '224,330p' file` to read a range instead, which is slower, races against bash's 60s timeout, and produces shell-quoted output. Expand the `"read"` entry with a worked example and the full alias set (`path`/`file`, `startLine`/`line`, `endLine`/`end_line`, `count` as an alternative to `end_line`), and tell the model to prefer it over `bash sed`. Signed-off-by: Chris Mason --- configs/prompts/main-agent.system.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/configs/prompts/main-agent.system.md b/configs/prompts/main-agent.system.md index ca3eb07..f6e44b1 100644 --- a/configs/prompts/main-agent.system.md +++ b/configs/prompts/main-agent.system.md @@ -7,7 +7,15 @@ Map each followup type to a tool: - "callees" → MCP find_calls - "search" → use the grep tool type, NOT semcode grep_functions. Use {"type": "grep", "pattern": "REGEX", "path": "DIR"} - "file" → find -- "read" → read +- "read" → read a file or a line range from one. Use + {"type": "read", "file": "path/to/file.c", "line": 100, + "end_line": 200} to read lines 100-200 inclusive; use "count" + instead of "end_line" to read N lines starting at `line`; omit + the range entirely to read the whole file. Aliases: `path` for + `file`, `startLine` for `line`, `endLine` for `end_line`. + Prefer this over `bash sed -n '...p'` — read is workspace-scoped, + emits a clean slice without shell quoting, and doesn't race + against your 60s bash timeout. - "git" → git. Readonly subcommands (log/show/diff/blame/status/...) plus `add` and `commit` for coding tasks that need to commit what they wrote. `--amend`, `--no-verify`, `--no-gpg-sign` are From 3f82fed5a952ce257b3a0d8c8bc4f2fd4e6a82d8 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 17:21:00 -0700 Subject: [PATCH 10/76] consent: don't strip leading dot from relative-path mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt-text path scanner was eating the leading `.` from relative-path tokens, because strip_token_punctuation trimmed `.` symmetrically from both ends. A prompt like read ../linux.net/kres-tues/report.md, write a fix ... became, after trim: `/linux.net/kres-tues/report.md` — both leading dots gone. That path doesn't exist, resolve_candidate's std::fs::metadata probe failed, no grant was added, and the subsequent `read` tool call got the bare "escapes workspace" error with no hint that the operator HAD in fact named the directory. Trim asymmetrically. Leading characters are limited to chars that can never legitimately start a path (quote-likes, opening brackets); `.`, `,`, `:`, `;`, `!`, `?` are stripped only from the right end where sentence punctuation actually lives. `./foo` and `../foo` survive intact. Regression-tested by a scanner test that builds a sibling dir under tmp and feeds the scanner `"read ../sibling/report.md, write a fix"`; the old code failed to grant, the new code grants the sibling directory. Signed-off-by: Chris Mason --- kres-core/src/consent.rs | 53 ++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/kres-core/src/consent.rs b/kres-core/src/consent.rs index 7b2cf09..c0f12d1 100644 --- a/kres-core/src/consent.rs +++ b/kres-core/src/consent.rs @@ -184,7 +184,17 @@ fn is_suspicious_grant(dir: &Path) -> bool { } fn strip_token_punctuation(s: &str) -> &str { - let trimmed = s.trim_matches(|c: char| { + // Trim asymmetrically. Operator prose wraps paths with leading + // quote-like chars (`` `/etc/hosts` ``, `"./foo"`, `(../bar)`) + // and trails them with sentence punctuation (`foo.c,` `foo.c.` + // `see foo:`). Stripping `.` from the LEFT would eat the leading + // dots of `./foo` and `../foo`, turning a relative path into an + // absolute one that doesn't exist — so left-trim is limited to + // chars that can never legitimately start a path. + let left_trimmed = s.trim_start_matches(|c: char| { + matches!(c, '`' | '(' | '[' | '{' | '\'' | '"' | '<') + }); + left_trimmed.trim_end_matches(|c: char| { matches!( c, ',' | '.' @@ -193,19 +203,14 @@ fn strip_token_punctuation(s: &str) -> &str { | '!' | '?' | '`' - | '(' | ')' - | '[' | ']' - | '{' | '}' | '\'' | '"' - | '<' | '>' ) - }); - trimmed + }) } fn looks_like_path(s: &str) -> bool { @@ -331,6 +336,40 @@ mod tests { std::fs::remove_dir_all(&d).ok(); } + #[test] + fn text_scanner_preserves_leading_dot_dot() { + // Regression: a prompt like "read ../sibling/report.md," used + // to get both leading dots stripped by the symmetric + // trim_matches, turning it into "/sibling/report.md" which + // doesn't exist — so consent was silently never granted. + let tmp = std::env::temp_dir(); + let base = tmp.join(format!("kres-scan-dotdot-{}", std::process::id())); + let sibling = base.join("sibling"); + std::fs::create_dir_all(&sibling).unwrap(); + let file = sibling.join("report.md"); + std::fs::write(&file, b"x").unwrap(); + let cwd = base.join("here"); + std::fs::create_dir_all(&cwd).unwrap(); + let s = ConsentStore::new(); + let msg = "read ../sibling/report.md, write a fix"; + let added = grant_paths_from_text(&s, &cwd, msg); + let canon_parent = sibling.canonicalize().unwrap(); + assert!( + added.iter().any(|g| g.dir == canon_parent), + "added={added:?}" + ); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn strip_token_punctuation_keeps_leading_relative_prefixes() { + assert_eq!(strip_token_punctuation("./foo,"), "./foo"); + assert_eq!(strip_token_punctuation("../foo."), "../foo"); + assert_eq!(strip_token_punctuation("`../foo`"), "../foo"); + assert_eq!(strip_token_punctuation("(./bar)"), "./bar"); + assert_eq!(strip_token_punctuation("\"../baz\""), "../baz"); + } + #[test] fn text_scanner_ignores_non_paths() { let s = ConsentStore::new(); From 4339a28864c3a85b1e9fc695ab3636cbac8b751a Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 17:26:18 -0700 Subject: [PATCH 11/76] main-agent: accept pattern/glob on find, end_line on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small tool-dispatch gaps, both of which burned turns in real sessions: 1) The `find` dispatch only read `name` as the -name glob. Session 9fee284e (2026-04-21 17:11) emitted `{"type":"find","pattern":"report.md"}` — `pattern` is how `grep` names this argument and is the word the model naturally reaches for. The dispatcher silently dropped it, `args.name` came back None, and find(1) ran with no filter and dumped the entire workspace tree. The model then fell back to `bash find`. 2) The `read` dispatch only read `endLine` (camelCase). The main-agent system prompt advertises `end_line` as the canonical name with `endLine` as an alias — exactly backwards from what the dispatcher accepted. The snake_case form went straight through to None. Fix by adding the obvious aliases in main_agent.rs' dispatch_non_mcp: `pattern` and `glob` as aliases for `find`'s `name`, and `end_line` as the primary lookup for `read`'s end line (falling back to `endLine`). No API surface change to the underlying tools — just the JSON field lookup in the action-parsing layer. Prompt: document find's arg shape (name / path / kind) and warn that a missing `name` dumps the whole tree; document git's shape (`command` as one string); mention grep's `glob` and `limit`. Same approach that was just applied to `read` — every in-tree tool now has a worked example in the action list rather than relying on the model to guess the field names from the followup-schema word. Tests: two new tokio tests in main_agent::tests exercise the two dispatch paths directly — find with `pattern` alias must produce a filtered result, read with `end_line` must clip at the requested line. Workspace test run is clean. Signed-off-by: Chris Mason --- configs/prompts/main-agent.system.md | 26 ++++++++--- kres-agents/src/main_agent.rs | 69 +++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/configs/prompts/main-agent.system.md b/configs/prompts/main-agent.system.md index f6e44b1..d2f0123 100644 --- a/configs/prompts/main-agent.system.md +++ b/configs/prompts/main-agent.system.md @@ -5,8 +5,18 @@ Map each followup type to a tool: - "source" → MCP find_function (or find_type for structs). Fallback: grep + read. - "callers" → MCP find_callers - "callees" → MCP find_calls -- "search" → use the grep tool type, NOT semcode grep_functions. Use {"type": "grep", "pattern": "REGEX", "path": "DIR"} -- "file" → find +- "search" → use the grep tool type, NOT semcode grep_functions. Use + {"type": "grep", "pattern": "REGEX", "path": "DIR", "glob": "*.c", + "limit": 200}. `glob` filters files; `limit` caps matches. +- "file" → locate a file by name via `find(1)`. Use + {"type": "find", "name": "report.md", "path": "sub/dir", + "kind": "f"}. `name` is the `-name` glob (accepts the literal + name or a `*.c`-style pattern); aliases `pattern` and `glob` are + accepted, but `name` matches the followup schema and is preferred. + `path` is the root dir (workspace-relative, defaults to the whole + workspace); `kind` is an optional `-type` char (`f`/`d`/`l`/...). + ALWAYS set `name` — a find with no filter dumps the entire tree + and is almost never what you want. - "read" → read a file or a line range from one. Use {"type": "read", "file": "path/to/file.c", "line": 100, "end_line": 200} to read lines 100-200 inclusive; use "count" @@ -16,11 +26,13 @@ Map each followup type to a tool: Prefer this over `bash sed -n '...p'` — read is workspace-scoped, emits a clean slice without shell quoting, and doesn't race against your 60s bash timeout. -- "git" → git. Readonly subcommands (log/show/diff/blame/status/...) - plus `add` and `commit` for coding tasks that need to commit what - they wrote. `--amend`, `--no-verify`, `--no-gpg-sign` are - rejected; `push`/`pull`/`fetch` are absent on purpose (the tool - is workspace-local). +- "git" → git. Use {"type": "git", "command": "log --oneline -20 -- path"}. + `command` is the subcommand + args as one string. Readonly + subcommands (log/show/diff/blame/status/...) plus `add` and + `commit` for coding tasks that need to commit what they wrote. + `--amend`, `--no-verify`, `--no-gpg-sign` are rejected; + `push`/`pull`/`fetch` are absent on purpose (the tool is + workspace-local). - "edit" → surgical string-replacement edit to an existing file. Use {"type": "edit", "file_path": "rel/path.c", "old_string": "...", "new_string": "...", "replace_all": false}. Same shape and diff --git a/kres-agents/src/main_agent.rs b/kres-agents/src/main_agent.rs index afb9bee..1345a98 100644 --- a/kres-agents/src/main_agent.rs +++ b/kres-agents/src/main_agent.rs @@ -443,6 +443,12 @@ async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (Strin } } "find" => { + // `name` is the canonical `-name` glob. Accept `pattern` + // and `glob` as aliases: the model naturally reaches for + // `pattern` because `grep` uses that key, and session + // 9fee284e (2026-04-21) burned a turn when a bare + // `{"type":"find","pattern":"report.md"}` ran find with no + // filter at all and dumped the whole workspace tree. let args = FindArgs { path: action .get("path") @@ -450,6 +456,8 @@ async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (Strin .map(String::from), name: action .get("name") + .or_else(|| action.get("pattern")) + .or_else(|| action.get("glob")) .and_then(|v| v.as_str()) .map(String::from), kind: action @@ -482,7 +490,8 @@ async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (Strin .and_then(|v| v.as_u64()) .map(|n| n as u32), end_line: action - .get("endLine") + .get("end_line") + .or_else(|| action.get("endLine")) .and_then(|v| v.as_u64()) .map(|n| n as u32), }; @@ -774,6 +783,64 @@ mod tests { assert!(s.is_empty()); } + #[tokio::test] + async fn find_accepts_pattern_alias_for_name() { + // Regression: the dispatcher used to read only `name`, so a + // model-emitted {"type":"find","pattern":"report.md"} ran + // find(1) with no -name filter and dumped the workspace tree. + let tmp = std::env::temp_dir().join(format!( + "kres-find-pattern-{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("report.md"), b"").unwrap(); + std::fs::write(tmp.join("other.md"), b"").unwrap(); + let action = json!({"type":"find","pattern":"report.md"}); + let (out, _) = dispatch_non_mcp(&tmp, &action).await; + assert!( + out.contains("report.md"), + "output missing report.md: {out}" + ); + assert!( + !out.contains("other.md"), + "filter not applied, got other.md: {out}" + ); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[tokio::test] + async fn read_accepts_end_line_snake_case() { + // The main-agent prompt advertises `end_line` as the canonical + // arg name, but the dispatcher used to only look up `endLine`. + let tmp = std::env::temp_dir().join(format!( + "kres-read-snake-{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let body = (1..=10) + .map(|n| format!("line {n}\n")) + .collect::(); + std::fs::write(tmp.join("f.txt"), body).unwrap(); + let action = + json!({"type":"read","file":"f.txt","line":3,"end_line":5}); + let (out, sym) = dispatch_non_mcp(&tmp, &action).await; + // dispatch returns a short summary string; the actual body + // lands on `sym.definition`. + assert!(!out.starts_with("[error]"), "unexpected error: {out}"); + let def = sym + .as_ref() + .and_then(|v| v.get("definition")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert!(def.contains("line 3\n"), "def missing line 3: {def:?}"); + assert!(def.contains("line 5\n"), "def missing line 5: {def:?}"); + assert!( + !def.contains("line 6\n"), + "def leaked line 6 past end_line: {def:?}" + ); + std::fs::remove_dir_all(&tmp).ok(); + } + #[tokio::test] async fn action_label_covers_each_kind() { assert_eq!( From d5fa1798c266d4279776f394d88c1f57c0845363 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 17:33:23 -0700 Subject: [PATCH 12/76] reaper: surface apply_code_edits outcomes into analysis trailer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_code_edits landed edits (or failed to) and logged one line per result to stderr, then threw the outcomes away. If the slow agent emitted an edit whose `old_string` was stale — either reconstructed from memory or invalidated by an earlier edit in the same batch — the failure died on stderr. The next slow-agent turn read the accumulated ledger and report.md and saw NOTHING about the failure, so it kept re-emitting the same broken anchor. Thread the results back. - apply_code_edits now returns Vec carrying per-file Ok(preview)/Err(message). stderr logs are unchanged for the operator; the structured vec is what the reaper uses. - format_applied_edits_trailer renders the vec into a short markdown section ("Edits applied (N/M, K FAILED):") with one bullet per edit. Failed entries are prefixed "[FAILED]" with the verbatim error text — same anchor text the slow agent needs to re-emit a corrected edit. - The reaper now runs persist_code_output and apply_code_edits BEFORE building effective_analysis, so the trailer can be folded in before the analysis lands in last_analysis, the accumulated ledger, and report.md. Follow-on tasks and /summary now see the same truth the operator sees on stderr. - The old post-analysis persist/apply pair is removed — duplication would double-write files. Multi-edit ordering, which was previously undocumented: - slow-code-agent-coding.system.md now states that code_edits apply in emission order against the file state AFTER prior edits in the same batch have landed, and that failures are NOT retried — they surface in the analysis trailer under [FAILED] so the next turn can re-emit. Guidance: prefer one edit per file per turn unless anchors are known not to collide. Tests: three unit tests cover the trailer formatter (failure markers, empty input, all-success no-FAILED-marker). Full workspace test run is clean. Signed-off-by: Chris Mason --- .../prompts/slow-code-agent-coding.system.md | 10 + kres-repl/src/session.rs | 262 ++++++++++++++---- 2 files changed, 212 insertions(+), 60 deletions(-) diff --git a/configs/prompts/slow-code-agent-coding.system.md b/configs/prompts/slow-code-agent-coding.system.md index 15fd2de..db046af 100644 --- a/configs/prompts/slow-code-agent-coding.system.md +++ b/configs/prompts/slow-code-agent-coding.system.md @@ -62,6 +62,16 @@ Output: JSON only, no fences, no preamble. CodeEdit shape (same as Claude Code's Edit): `{file_path, old_string, new_string, replace_all?}`. Leave `replace_all` off (defaults to false) and `old_string` must match exactly once. `old_string` and `new_string` are VERBATIM byte sequences; include enough surrounding context to make `old_string` unique in the file. +Multi-edit ordering contract: entries in `code_edits` apply IN ORDER, +each against the file's state AFTER prior entries in the same batch +have landed. If two edits touch the same file, the second one's +`old_string` must match the result of the first, not the original. +Edits that fail (anchor not found, ambiguous, workspace escape) are +not retried — the failure message is appended to the task analysis +trailer under `[FAILED]` so you can re-emit a corrected edit on the +next turn. Prefer one edit per file per turn unless you are certain +the anchors don't collide. + CODE_OUTPUT — primary artifact: - 'code_output' is an array of {path, content, purpose} records. EACH file you produce is one entry. Use forward-slash relative paths; they land under `/code/` on disk. - 'path' is a relative path with a sensible extension (e.g. `reproduce.c`, `Makefile`, `reproducer/trigger.py`, `tests/verify.sh`). Pick filenames that a reader cloning the results directory can run. diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index f30c57c..446e8f0 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -659,6 +659,37 @@ impl Session { if matches!(r.state, TaskState::Done | TaskState::Errored) { *interrupted_for_reaper.lock().await = None; } + // Coding-mode side effects: persist code_output + // files and apply code_edits BEFORE we build the + // analysis trailer — we want per-edit results + // folded into effective_analysis so failures are + // visible to the next slow-agent turn, the goal + // agent, and /summary (not just stderr). + if matches!(r.mode, kres_core::TaskMode::Coding) + && !r.code_output.is_empty() + { + persist_code_output( + &code_output_root_for_reaper, + &r.name, + &r.code_output, + ) + .await; + } + let applied_edits: Vec = if matches!( + r.mode, + kres_core::TaskMode::Coding + ) && !r.code_edits.is_empty() + { + apply_code_edits( + &code_output_root_for_reaper, + &r.name, + &r.code_edits, + ) + .await + } else { + Vec::new() + }; + // For Coding-mode tasks the slow agent is told to // keep prose short and put the artifact in // `code_output`. But check_goal only reads the @@ -668,44 +699,55 @@ impl Session { // sitting on disk (session 597b4bf7). Append a // short trailer listing what landed so the goal // agent has concrete evidence to judge on. - let effective_analysis = if r.code_output.is_empty() { + let effective_analysis = if r.code_output.is_empty() + && applied_edits.is_empty() + { r.analysis.clone() } else { let mut s = r.analysis.clone(); if !s.is_empty() && !s.ends_with('\n') { s.push('\n'); } - s.push_str("\n---\nFiles written to workspace:\n"); - for f in &r.code_output { - let purpose = if f.purpose.is_empty() { - "" - } else { - &f.purpose - }; - if purpose.is_empty() { - s.push_str(&format!("- {}\n", f.path)); - } else { - s.push_str(&format!("- {} — {}\n", f.path, purpose)); - } - // Include the head of the file so the - // goal agent can see the actual script - // body, not just the filename. Cap at - // 2000 chars so a very long artifact - // doesn't blow out the goal-check token - // budget. - let head: String = f.content.chars().take(2000).collect(); - s.push_str("```\n"); - s.push_str(&head); - if f.content.chars().count() > 2000 { - s.push_str("\n… (truncated, full content at "); - s.push_str(&f.path); - s.push_str(")\n"); - } - if !head.ends_with('\n') { - s.push('\n'); + if !r.code_output.is_empty() { + s.push_str("\n---\nFiles written to workspace:\n"); + for f in &r.code_output { + let purpose = if f.purpose.is_empty() { + "" + } else { + &f.purpose + }; + if purpose.is_empty() { + s.push_str(&format!("- {}\n", f.path)); + } else { + s.push_str(&format!( + "- {} — {}\n", + f.path, purpose + )); + } + // Include the head of the file so the + // goal agent can see the actual script + // body, not just the filename. Cap at + // 2000 chars so a very long artifact + // doesn't blow out the goal-check + // token budget. + let head: String = + f.content.chars().take(2000).collect(); + s.push_str("```\n"); + s.push_str(&head); + if f.content.chars().count() > 2000 { + s.push_str( + "\n… (truncated, full content at ", + ); + s.push_str(&f.path); + s.push_str(")\n"); + } + if !head.ends_with('\n') { + s.push('\n'); + } + s.push_str("```\n"); } - s.push_str("```\n"); } + s.push_str(&format_applied_edits_trailer(&applied_edits)); s }; if !effective_analysis.is_empty() { @@ -743,31 +785,11 @@ impl Session { } } } - // Coding tasks skip the merger / consolidator / findings - // pipeline entirely. Persist any emitted source files - // under /code/ and move on — the goal agent - // still runs against r.analysis (treated as notes by - // the goal system prompt). - if matches!(r.mode, kres_core::TaskMode::Coding) - && !r.code_output.is_empty() - { - persist_code_output( - &code_output_root_for_reaper, - &r.name, - &r.code_output, - ) - .await; - } - if matches!(r.mode, kres_core::TaskMode::Coding) - && !r.code_edits.is_empty() - { - apply_code_edits( - &code_output_root_for_reaper, - &r.name, - &r.code_edits, - ) - .await; - } + // Coding tasks skip the merger / consolidator / + // findings pipeline entirely — the goal agent + // runs against effective_analysis (now including + // the edit trailer) and the reaped files already + // landed above. let pre_size = mgr_for_reaper.findings_snapshot().await.len(); // /stop is latched: skip every inference-heavy // reaper post-step (findings merger, goal check, @@ -2810,15 +2832,33 @@ pub async fn build_orchestrator( /// segments (`..`) so a malformed model reply can't drop files /// outside the workspace root. Each file is written with a /// tmp + rename so a crash doesn't leave a partial artifact. +/// One applied (or attempted) CodeEdit. The reaper folds these +/// back into the task's analysis trailer so a failure ("old_string +/// not found", "ambiguous match") is visible to the NEXT slow-agent +/// turn instead of dying on stderr. +pub(crate) struct AppliedEdit { + pub file_path: String, + /// `Ok(msg)` carries the per-edit success preview from + /// `edit_file` (replacement count + before/after sizes + + /// 5-line context snippet). `Err(msg)` carries the error text + /// the slow agent needs to see to correct its next emission. + pub result: Result, +} + /// Apply each CodeEdit emitted by a coding-mode task to its target -/// file on disk via kres_agents::tools::edit_file. Logs one line -/// per edit with replacement count + before/after sizes; errors -/// are logged but don't abort the batch — other edits still run. +/// file on disk via kres_agents::tools::edit_file. Returns a vector +/// of `AppliedEdit`s so the reaper can fold outcomes into the +/// task's analysis trailer; also logs one line per edit to stderr +/// for the operator. Edits apply in emission order — a later edit +/// whose `old_string` was invalidated by an earlier one in the same +/// batch will fail with a normal "not found" error; the caller +/// (slow agent) sees that in the trailer and can re-emit. async fn apply_code_edits( workspace: &Path, task_name: &str, edits: &[kres_core::CodeEdit], -) { +) -> Vec { + let mut results: Vec = Vec::with_capacity(edits.len()); let mut applied = 0usize; let mut failed = 0usize; for e in edits { @@ -2832,13 +2872,22 @@ async fn apply_code_edits( Ok(msg) => { applied += 1; kres_core::async_eprintln!("[coding-edit] {msg}"); + results.push(AppliedEdit { + file_path: e.file_path.clone(), + result: Ok(msg), + }); } Err(err) => { failed += 1; + let text = err.to_string(); kres_core::async_eprintln!( - "[coding-edit] {}: {err}", + "[coding-edit] {}: {text}", e.file_path ); + results.push(AppliedEdit { + file_path: e.file_path.clone(), + result: Err(text), + }); } } } @@ -2846,6 +2895,57 @@ async fn apply_code_edits( "[coding-edit] {task_name}: applied {applied}/{} edit(s) ({failed} failed)", edits.len() ); + results +} + +/// Render the list of AppliedEdit into a trailer section for the +/// task's analysis text. Failed edits are called out with +/// "[FAILED]" so the next slow-agent turn can grep for them; the +/// full error message is included verbatim so the model has the +/// exact anchor text it needs to re-emit a corrected edit. +pub(crate) fn format_applied_edits_trailer(edits: &[AppliedEdit]) -> String { + if edits.is_empty() { + return String::new(); + } + let applied = edits.iter().filter(|e| e.result.is_ok()).count(); + let failed = edits.len() - applied; + let mut s = String::new(); + s.push_str("\n---\nEdits applied ("); + s.push_str(&applied.to_string()); + s.push('/'); + s.push_str(&edits.len().to_string()); + if failed > 0 { + s.push_str(", "); + s.push_str(&failed.to_string()); + s.push_str(" FAILED"); + } + s.push_str("):\n"); + for e in edits { + match &e.result { + Ok(msg) => { + s.push_str("- "); + s.push_str(&e.file_path); + // msg starts with "[edit ] N replacement(s) (..." + // — drop the `[edit ] ` prefix to keep the trailer + // tight; the path is already on the line. + let tail = msg.splitn(2, "] ").nth(1).unwrap_or(msg); + s.push_str(": "); + // Only keep the first line of the preview block — the + // full 5-line context lives in the stderr log. + let first = tail.split('\n').next().unwrap_or(tail); + s.push_str(first); + s.push('\n'); + } + Err(err) => { + s.push_str("- [FAILED] "); + s.push_str(&e.file_path); + s.push_str(": "); + s.push_str(err); + s.push('\n'); + } + } + } + s } async fn persist_code_output( @@ -3250,6 +3350,48 @@ mod tests { assert_eq!(truncate("abc", 5), "abc"); } + #[test] + fn applied_edits_trailer_reports_failures() { + let edits = vec![ + AppliedEdit { + file_path: "a.c".into(), + result: Ok( + "[edit /tmp/a.c] 1 replacement(s) (before: 100c, after: 98c)\n ctx1\n ctx2\n".into(), + ), + }, + AppliedEdit { + file_path: "b.c".into(), + result: Err( + "edit: old_string not found in /tmp/b.c — re-read the file and supply bytes copied verbatim from the current contents".into(), + ), + }, + ]; + let t = format_applied_edits_trailer(&edits); + assert!(t.contains("Edits applied (1/2, 1 FAILED):"), "got {t}"); + assert!(t.contains("- a.c: 1 replacement(s)"), "got {t}"); + assert!(t.contains("[FAILED] b.c"), "got {t}"); + assert!(t.contains("old_string not found"), "got {t}"); + // Success entry should keep first preview line only, not the + // multi-line context block. + assert!(!t.contains("ctx2"), "preview context leaked: {t}"); + } + + #[test] + fn applied_edits_trailer_empty_on_no_edits() { + assert_eq!(format_applied_edits_trailer(&[]), ""); + } + + #[test] + fn applied_edits_trailer_all_success_no_failed_marker() { + let edits = vec![AppliedEdit { + file_path: "a.c".into(), + result: Ok("[edit /tmp/a.c] 2 replacement(s) (...)\n".into()), + }]; + let t = format_applied_edits_trailer(&edits); + assert!(t.contains("Edits applied (1/1):"), "got {t}"); + assert!(!t.contains("FAILED"), "got {t}"); + } + #[test] fn truncate_ellipsises_long() { assert_eq!(truncate("abcdef", 3), "abc…"); From 2c560997b93b9c30f6c650bd68c7a7b0a0f173b0 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 17:36:08 -0700 Subject: [PATCH 13/76] main-agent: return read-tool body inline, not just a size header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main-agent read dispatch was returning a header-only string to the turn log: Read drivers/net/.../bnxt_xdp.c:270-371 (2587 chars), symbol 'bnxt_xdp.c:270-371' …with zero bytes of the actual file content. The bytes were attached to the symbol pool that forwards to the SLOW agent, but the MAIN agent — the one choosing its next action — saw only the ack. When it wanted lines 270-370 of a file to reason about the XDP redirect path, it called `read` with `end_line` (worked), got back a size header with no content, tried again with `count` (worked), got the same header with no content, then gave up and ran `bash sed -n '270,370p' ...` which DOES put the bytes in [stdout]. Session 04365bed (2026-04-21, turns 1-3) is the anchor. The prompt already says "prefer read over bash sed"; the tool was the one failing to hold up its end. Fix: append the file body to the turn-log text after the header. Truncate at TOOL_OUTPUT_CAP_GREP_FIND (20k chars) — the same envelope grep and find use — so a whole-file read with no range can't blow the turn budget. The symbol attachment is unchanged, so the slow agent still receives the full content via its symbol pool and the dedupe cache continues to function. Test: `read_text_result_contains_file_body` reads lines 3-5 of a tmp file via dispatch_non_mcp and asserts both "line 3" and "line 5" appear in the returned text alongside the existing header. Full workspace test run is clean. Signed-off-by: Chris Mason --- kres-agents/src/main_agent.rs | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/kres-agents/src/main_agent.rs b/kres-agents/src/main_agent.rs index 1345a98..61aa5a2 100644 --- a/kres-agents/src/main_agent.rs +++ b/kres-agents/src/main_agent.rs @@ -42,7 +42,7 @@ use crate::{ }, tools::{ bash_run, edit_file, find, git, grep, read_file_range, truncate_output, BashArgs, EditArgs, - FindArgs, GitArgs, GrepArgs, ReadArgs, TOOL_OUTPUT_CAP_MCP, + FindArgs, GitArgs, GrepArgs, ReadArgs, TOOL_OUTPUT_CAP_GREP_FIND, TOOL_OUTPUT_CAP_MCP, }, }; @@ -520,7 +520,21 @@ async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (Strin "line": start, "definition": def.clone(), }); - let short = format!( + // Include the actual content in the turn-log + // text. The symbol pool (Some(sym) below) carries + // `def` to the slow agent, but the main agent + // itself reads `text` to decide its next action + // — and without the bytes inline it can't. Before + // this, a `read lines 270-370` came back as + // "Read file:270-370 (2584 chars), symbol 'x'" + // with no content, so the model resorted to + // `bash sed -n '270,370p' ...` which DOES put the + // bytes in [stdout] (session 04365bed, turn 3 of + // 2026-04-21). Truncate at the same envelope + // grep/find use so a runaway whole-file read + // can't blow the turn budget. + let body = truncate_output(&def, TOOL_OUTPUT_CAP_GREP_FIND); + let header = format!( "Read {}:{}-{} ({} chars), symbol '{}'", sym.get("filename").and_then(|v| v.as_str()).unwrap_or(""), start, @@ -528,7 +542,8 @@ async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (Strin def.len(), sym.get("name").and_then(|v| v.as_str()).unwrap_or(""), ); - (short, Some(sym)) + let text = format!("{header}\n{body}"); + (text, Some(sym)) } Err(e) => (format!("[error] {e}"), None), } @@ -808,6 +823,33 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[tokio::test] + async fn read_text_result_contains_file_body() { + // Regression: session 04365bed (2026-04-21 turn 3/5) called + // `read lines 270-370` twice, each time received only a + // `Read file:N-M (X chars), symbol '...'` header with NO + // content in the turn-log text — the bytes were going into + // the symbol pool only. Model gave up and used `bash sed`. + // The turn-log text must carry the content inline. + let tmp = std::env::temp_dir().join(format!( + "kres-read-text-{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let body = (1..=10) + .map(|n| format!("line {n}\n")) + .collect::(); + std::fs::write(tmp.join("f.txt"), body).unwrap(); + let action = + json!({"type":"read","file":"f.txt","line":3,"end_line":5}); + let (text, _sym) = dispatch_non_mcp(&tmp, &action).await; + assert!(text.contains("line 3\n"), "no body in text: {text:?}"); + assert!(text.contains("line 5\n"), "no body in text: {text:?}"); + // Header still present for at-a-glance scanning. + assert!(text.contains("Read f.txt:"), "no header: {text:?}"); + std::fs::remove_dir_all(&tmp).ok(); + } + #[tokio::test] async fn read_accepts_end_line_snake_case() { // The main-agent prompt advertises `end_line` as the canonical From 86aca4f36c384afcd687f248d1f74907ed5b837a Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 18:05:26 -0700 Subject: [PATCH 14/76] main-agent: gate non-MCP actions behind an allowlist, bash off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators report the bash tool being reached for as a general escape hatch for things the typed tools already cover — `bash sed` for a range read, `bash find` for a filename locate. Even after the prompt was fixed to say "prefer read over bash sed" and the read tool was fixed to actually return file bytes (commit 2c56099, 2026-04-21), nothing stops a model that learns bash from training memory: a `{"type":"bash","command":"..."}` action would hit the unconditional `"bash" =>` branch of dispatch_non_mcp. Add a real gate. Settings schema (kres-repl/src/settings.rs) ------------------------------------------- - New `actions: { allowed: Option> }` block in settings.json. - Semantics of `actions.allowed`: * `null` or key absent → falls back to DEFAULT_ALLOWED_ACTIONS (grep/find/read/git/edit — bash excluded). * `[]` → empty is the EXPLICIT "deny every non-MCP action" signal; the dispatcher enforces it (no collapse-to-defaults). * `["read", ...]` → exact set, no defaults merged in. - `load_merged` layers `/.kres/settings.json` over `~/.kres/settings.json`. Project values replace global field-by-field; allowlists don't union — more specific wins. The split helpers `load_merged_with_paths` and `apply_project_overrides` exist so the merge is testable without mocking the operator's real home directory. - `effective_allowed_actions(cli_extras)` returns the final set. CLI `--allow` tokens are additive on top of whatever the settings resolved to. Unknown tokens (not in KNOWN_ACTION_TYPES and not the `"all"` escape hatch) are DROPPED — a dead entry in the allowlist serves no purpose and masks the typo. The companion `warn_unknown_action_tokens` surfaces them with a closest-match suggestion (Levenshtein distance ≤ 2). - KNOWN_ACTION_TYPES lists every action the main agent might emit: grep/find/read/git/edit/bash PLUS mcp. "mcp" is a no-op in the allowlist (MCP is gated by mcp.json server registration), included only to keep the typo-warner from false-positive-flagging `--allow mcp`. CLI (kres/src/main.rs) ---------------------- - New `--allow ACTION` flag. Repeatable (`--allow bash --allow git`) or comma-separated (`--allow bash,git`). Adds to whatever settings.json resolved to. Special token `--allow all` expands to every action type the dispatcher knows (including bash) — the one-off escape hatch. - kres loads merged settings via `Settings::load_merged(workspace)` at startup, calls `warn_unknown_action_tokens` before computing the set (so the operator sees a typo warning BEFORE the flag silently no-ops), then builds the Arc> shared into MainAgent. - Startup banner prints the resolved allowlist and distinguishes "bash disabled by default" from "bash disabled by explicit allowlist in settings.json" — both are valid operator choices; the wording respects each. The banner is gated on a main-agent config being resolved, so --summary and other non-LLM modes don't see noise about an allowlist that never runs. Dispatch gate (kres-agents/src/main_agent.rs) --------------------------------------------- - MainAgent carries `allowed_actions: Arc>`. - dispatch_non_mcp rejects any action whose `type` is not in the set with an `[error] action type 'X' is not in the allowed-action list for this session (list). To enable it, add 'X' to actions.allowed in ~/.kres/settings.json (or /.kres/settings.json) or re-run kres with '--allow X'.` string that names the allowed set and the fix paths. - An empty set is an explicit deny-all (error message surfaces "none — every non-MCP action is denied this session") — NOT a fall-back-to-allow-everything. Tests pass explicit sets for the tools they exercise. - Malformed actions (missing `type` field) bypass the gate so they hit the existing `unknown action type: ?` error — a malformed action is not a gated action and deserves a clearer error. Prompt (configs/prompts/main-agent.system.md) --------------------------------------------- - The `bash` entry documents that bash is OFF by default and shows the rejection shape so the model doesn't re-emit the same bash action hoping it lands. Pointer to the typed alternatives (read/grep/find/git). Template (configs/settings.json) -------------------------------- - The installed template gains `_actions_doc` and `_actions_example` fields so an operator who opens ~/.kres/settings.json can see the schema and a commented-out example. serde_json ignores unknown keys so behaviour is unchanged. Tests ----- kres-repl/src/settings.rs (11 new): - default_allowlist_excludes_bash - cli_allow_adds_bash - cli_allow_all_adds_everything (asserts every DEFAULT_ALLOWED_ACTIONS entry + bash so a future list shrink fails here, not silently) - settings_allowlist_replaces_default - project_only_allowlist_replaces_defaults - load_merged_project_overrides_global (real merge, global + per- project files written and layered) - load_merged_global_only_when_no_project - explicit_empty_allowlist_stays_empty - warn_unknown_action_tokens_flags_typos - warn_unknown_action_tokens_silent_for_clean_input - closest_known_action_suggests_within_edit_distance_two kres-agents/src/main_agent.rs (3 new): - dispatch_rejects_action_not_in_allowlist (gate returns informative error with --allow / settings.json hints) - empty_allowlist_denies_all_actions (pins the corrected contract after the first review) - malformed_action_reports_unknown_not_gated (no-`type` field action hits "unknown action type" catch-all, NOT the allowlist error) kres/src/main.rs (2 new): - allow_flag_accepts_comma_separated (value_delimiter = ',' + repeatable both work) - allow_flag_defaults_to_empty Full workspace test run is clean: 14 kres-repl, 140 kres-agents, 51 kres unit tests pass. Signed-off-by: Chris Mason --- configs/prompts/main-agent.system.md | 8 + configs/settings.json | 4 + kres-agents/src/main_agent.rs | 142 ++++++++- kres-repl/src/settings.rs | 452 ++++++++++++++++++++++++++- kres/src/main.rs | 83 ++++- 5 files changed, 678 insertions(+), 11 deletions(-) diff --git a/configs/prompts/main-agent.system.md b/configs/prompts/main-agent.system.md index d2f0123..1edea47 100644 --- a/configs/prompts/main-agent.system.md +++ b/configs/prompts/main-agent.system.md @@ -50,6 +50,14 @@ Map each followup type to a tool: capped at 20k chars. This tool is mainly used by the coding flow to compile and run generated source — do NOT use it to fish around with grep/find/rm or to query external services. + NOTE: `bash` is OFF by default — it is only available when the + operator adds it to the action allowlist (via settings.json or + `--allow bash`). When it is not enabled, a `bash` action will + come back with `[error] action type 'bash' is not in the + allowed-action list for this session (...)`. Do not re-emit the + same bash action hoping it lands: pick one of the typed tools + (`read` for a file range, `grep` for text search, `find` for + filenames, `git` for repo history) instead. - "question" → respond directly You can issue MULTIPLE tool calls at once using (plural). This runs them in parallel: diff --git a/configs/settings.json b/configs/settings.json index 93ef4e3..0f19ae3 100644 --- a/configs/settings.json +++ b/configs/settings.json @@ -4,5 +4,9 @@ "slow": "@SLOW_MODEL@", "main": "@MODEL@", "todo": "@MODEL@" + }, + "_actions_doc": "Uncomment the 'actions' block below to gate the main agent's non-MCP tools. When omitted, the default allowlist is grep/find/read/git/edit (bash is off by default). An explicit list REPLACES the default. Per-session overrides: --allow bash, --allow all. A project-local /.kres/settings.json overrides this global one.", + "_actions_example": { + "allowed": ["grep", "find", "read", "git", "edit"] } } diff --git a/kres-agents/src/main_agent.rs b/kres-agents/src/main_agent.rs index 61aa5a2..abe997a 100644 --- a/kres-agents/src/main_agent.rs +++ b/kres-agents/src/main_agent.rs @@ -73,6 +73,14 @@ pub struct MainAgent { pub mcp_servers: HashMap>>, pub logger: Option>, pub usage: Option>, + /// Allowlist of non-MCP action types the main agent is permitted + /// to dispatch this session. An emitted action whose `type` is + /// not in the set is rejected with an error string that names + /// the alternatives and points at `--allow`/`settings.json`. + /// Empty = allow all (for tests and callers that don't care). + /// Resolved from settings.json layered with `--allow` CLI flags + /// by `kres-repl::Settings::effective_allowed_actions`. + pub allowed_actions: Arc>, } impl MainAgent { @@ -371,8 +379,9 @@ impl MainAgent { let mut non_mcp_futures = Vec::with_capacity(non_mcp.len()); for (idx, action) in non_mcp { let ws = self.workspace.clone(); + let allowed = self.allowed_actions.clone(); non_mcp_futures.push(async move { - let out = dispatch_non_mcp(&ws, &action).await; + let out = dispatch_non_mcp(&ws, &action, &allowed).await; (idx, action, out) }); } @@ -413,8 +422,37 @@ impl MainAgent { /// Dispatch a single non-MCP action. Returns (text_output, optional /// symbol). Actions with unknown types land in context with an error /// message so they don't silently vanish. -async fn dispatch_non_mcp(workspace: &std::path::Path, action: &Value) -> (String, Option) { +/// +/// `allowed_actions` is the session allowlist (resolved from +/// settings.json + CLI `--allow`). Every action's `type` must be +/// present in the set; an empty set means "deny all non-MCP +/// actions" (the explicit `"allowed": []` in settings.json +/// semantic). Malformed actions (missing `type` field) bypass the +/// gate so they hit the existing unknown-type error below — a +/// malformed action is not a gated action and deserves a clearer +/// error. MCP actions are gated separately (by server registration) +/// and don't enter this function. +async fn dispatch_non_mcp( + workspace: &std::path::Path, + action: &Value, + allowed_actions: &std::collections::BTreeSet, +) -> (String, Option) { let ty = action.get("type").and_then(|v| v.as_str()).unwrap_or("?"); + if ty != "?" && !allowed_actions.contains(ty) { + let allowed_list: Vec<&str> = + allowed_actions.iter().map(|s| s.as_str()).collect(); + let list_display = if allowed_list.is_empty() { + "none — every non-MCP action is denied this session".to_string() + } else { + allowed_list.join(", ") + }; + return ( + format!( + "[error] action type '{ty}' is not in the allowed-action list for this session ({list_display}). To enable it, add `{ty}` to `actions.allowed` in ~/.kres/settings.json (or /.kres/settings.json) or re-run kres with `--allow {ty}`." + ), + None, + ); + } match ty { "grep" => { let args = GrepArgs { @@ -793,6 +831,7 @@ mod tests { mcp_servers: HashMap::new(), logger: None, usage: None, + allowed_actions: Arc::new(std::collections::BTreeSet::new()), }; let s = a.mcp_tool_descriptions().await; assert!(s.is_empty()); @@ -811,7 +850,9 @@ mod tests { std::fs::write(tmp.join("report.md"), b"").unwrap(); std::fs::write(tmp.join("other.md"), b"").unwrap(); let action = json!({"type":"find","pattern":"report.md"}); - let (out, _) = dispatch_non_mcp(&tmp, &action).await; + let allow: std::collections::BTreeSet = + ["find"].iter().map(|s| s.to_string()).collect(); + let (out, _) = dispatch_non_mcp(&tmp, &action, &allow).await; assert!( out.contains("report.md"), "output missing report.md: {out}" @@ -842,7 +883,9 @@ mod tests { std::fs::write(tmp.join("f.txt"), body).unwrap(); let action = json!({"type":"read","file":"f.txt","line":3,"end_line":5}); - let (text, _sym) = dispatch_non_mcp(&tmp, &action).await; + let allow: std::collections::BTreeSet = + ["read"].iter().map(|s| s.to_string()).collect(); + let (text, _sym) = dispatch_non_mcp(&tmp, &action, &allow).await; assert!(text.contains("line 3\n"), "no body in text: {text:?}"); assert!(text.contains("line 5\n"), "no body in text: {text:?}"); // Header still present for at-a-glance scanning. @@ -865,7 +908,9 @@ mod tests { std::fs::write(tmp.join("f.txt"), body).unwrap(); let action = json!({"type":"read","file":"f.txt","line":3,"end_line":5}); - let (out, sym) = dispatch_non_mcp(&tmp, &action).await; + let allow: std::collections::BTreeSet = + ["read"].iter().map(|s| s.to_string()).collect(); + let (out, sym) = dispatch_non_mcp(&tmp, &action, &allow).await; // dispatch returns a short summary string; the actual body // lands on `sym.definition`. assert!(!out.starts_with("[error]"), "unexpected error: {out}"); @@ -883,6 +928,93 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[tokio::test] + async fn dispatch_rejects_action_not_in_allowlist() { + // With a non-empty allowlist that excludes "bash", a bash + // action must bounce with an error that names the allowed + // set and points at --allow / settings.json. + let tmp = std::env::temp_dir().join(format!( + "kres-gate-bash-{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let action = + json!({"type":"bash","command":"echo should not run > /tmp/gated"}); + let mut allow = std::collections::BTreeSet::new(); + allow.insert("read".to_string()); + allow.insert("grep".to_string()); + let (out, sym) = dispatch_non_mcp(&tmp, &action, &allow).await; + assert!(sym.is_none(), "gated action shouldn't emit a symbol"); + assert!(out.contains("[error]"), "got {out}"); + assert!( + out.contains("'bash' is not in the allowed-action list"), + "error missing action name: {out}" + ); + assert!(out.contains("--allow bash"), "error missing fix hint: {out}"); + assert!(out.contains("settings.json"), "error missing settings hint: {out}"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[tokio::test] + async fn empty_allowlist_denies_all_actions() { + // Contract: an empty allowlist means "deny every non-MCP + // action" — it is the explicit `"allowed": []` in + // settings.json semantic. Previously the dispatcher + // short-circuited on is_empty() and allowed everything, + // which silently neutered an operator's lockdown. + let tmp = std::env::temp_dir().join(format!( + "kres-gate-empty-{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("f.txt"), "hello\n").unwrap(); + let action = json!({"type":"read","file":"f.txt"}); + let allow = std::collections::BTreeSet::new(); + let (out, sym) = dispatch_non_mcp(&tmp, &action, &allow).await; + assert!(sym.is_none(), "denied action shouldn't emit a symbol"); + assert!(out.contains("[error]"), "expected error, got {out}"); + assert!( + out.contains("'read' is not in the allowed-action list"), + "expected deny message, got {out}" + ); + assert!( + out.contains("none — every non-MCP action is denied"), + "expected empty-list message, got {out}" + ); + // The file we wrote should remain unread (the dispatcher + // bailed before touching it). + assert!( + !out.contains("hello"), + "read tool ran despite deny: {out}" + ); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[tokio::test] + async fn malformed_action_reports_unknown_not_gated() { + // An action with no `type` field should hit the existing + // "unknown action type" error, NOT the allowlist-gate error. + // A malformed action is not a gated action. + let tmp = std::env::temp_dir().join(format!( + "kres-malformed-{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let action = json!({"command": "nope"}); // no `type` field + let allow: std::collections::BTreeSet = + ["read", "grep"].iter().map(|s| s.to_string()).collect(); + let (out, _) = dispatch_non_mcp(&tmp, &action, &allow).await; + assert!( + out.contains("unknown action type"), + "expected unknown-type error, got {out}" + ); + assert!( + !out.contains("not in the allowed-action list"), + "unexpected allowlist error for malformed action: {out}" + ); + std::fs::remove_dir_all(&tmp).ok(); + } + #[tokio::test] async fn action_label_covers_each_kind() { assert_eq!( diff --git a/kres-repl/src/settings.rs b/kres-repl/src/settings.rs index 562eb1a..1db9a4e 100644 --- a/kres-repl/src/settings.rs +++ b/kres-repl/src/settings.rs @@ -1,6 +1,7 @@ -//! Per-user default settings, loaded from `~/.kres/settings.json`. +//! Per-user default settings, loaded from `~/.kres/settings.json`, +//! optionally overlaid by a project-local `/.kres/settings.json`. //! -//! Today the file carries only per-agent default model ids: +//! Schema: //! //! ```json //! { @@ -9,6 +10,9 @@ //! "slow": "claude-opus-4-7", //! "main": "claude-sonnet-4-6", //! "todo": "claude-sonnet-4-6" +//! }, +//! "actions": { +//! "allowed": ["grep", "find", "read", "git", "edit", "bash"] //! } //! } //! ``` @@ -19,20 +23,70 @@ //! 2. the matching `models.` string in settings.json; //! 3. `Model::sonnet_4_6()` (lowest — hard-coded fallback). //! -//! A missing or empty settings.json is not an error — every field is -//! optional and the default struct just returns None from every -//! lookup. +//! Precedence for the action allowlist: +//! 1. CLI `--allow ` flags (additive on top of the list +//! below — an operator saying `--allow bash` gets bash for this +//! session regardless of what the files say); +//! 2. project `/.kres/settings.json` `actions.allowed` if set; +//! 3. global `~/.kres/settings.json` `actions.allowed` if set; +//! 4. `DEFAULT_ALLOWED_ACTIONS` (grep/find/read/git/edit — bash is +//! excluded by default because operators report it gets used as +//! a general escape hatch for things the typed tools already +//! handle). +//! +//! A missing or empty settings.json file is not an error — every +//! field is optional and the default struct just returns None from +//! every lookup. Distinct from this: an empty `actions.allowed` +//! array in a PRESENT file (`{"actions":{"allowed":[]}}`) is the +//! explicit "deny every non-MCP action" signal and the dispatcher +//! enforces it — see precedence list above. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use serde::Deserialize; use kres_llm::Model; +/// Action types (non-MCP dispatch) that are allowed when no +/// settings.json has spoken and the operator didn't pass --allow. +/// Bash is deliberately excluded — operators report it being used +/// as an escape hatch for things the typed tools already cover +/// (`bash sed` for range reads, `bash find` for file locates). +/// Coding flows that genuinely need `cc && ./repro` can opt in via +/// `--allow bash` or via settings.actions.allowed. +pub const DEFAULT_ALLOWED_ACTIONS: &[&str] = + &["grep", "find", "read", "git", "edit"]; + +/// Every action type the main agent might emit. Used for typo +/// detection when an operator writes `--allow bsah` or sticks +/// `"fnid"` in `actions.allowed`. Keep in sync with the `match ty +/// { ... }` arms in `kres_agents::main_agent::dispatch_non_mcp` +/// PLUS the separately-routed `"mcp"` action. `"mcp"` is listed so +/// `--allow mcp` doesn't false-positive as a typo; the allowlist +/// gate in dispatch_non_mcp never consults the `"mcp"` entry (MCP +/// actions are gated by mcp.json server registration, not this +/// list), so including it here is effectively documentation. +pub const KNOWN_ACTION_TYPES: &[&str] = + &["grep", "find", "read", "git", "edit", "bash", "mcp"]; + #[derive(Debug, Clone, Deserialize, Default)] pub struct Settings { #[serde(default)] pub models: Models, + #[serde(default)] + pub actions: ActionSettings, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ActionSettings { + /// Explicit allowlist. When `Some`, replaces the built-in + /// `DEFAULT_ALLOWED_ACTIONS` entirely. When `None`, the default + /// list is used. Project-local settings.json replaces the global + /// list rather than unioning — the usual "more specific config + /// wins" behaviour. + #[serde(default)] + pub allowed: Option>, } #[derive(Debug, Clone, Deserialize, Default)] @@ -90,6 +144,150 @@ impl Settings { } } + /// Load the global settings and overlay per-project settings + /// from `/.kres/settings.json`. Project values + /// take precedence field-by-field: + /// - `models.*`: project's Some replaces global's Some; + /// project's None leaves the global value in place. + /// - `actions.allowed`: project's Some REPLACES global's Some + /// (allowlists don't union — the more specific config wins). + /// A missing project settings file is not an error. + pub fn load_merged(project_root: &Path) -> Self { + let proj_path = project_root.join(".kres").join("settings.json"); + Self::load_merged_with_paths(Self::default_path().as_deref(), &proj_path) + } + + /// Testable core of `load_merged`. `global` is the path to + /// `~/.kres/settings.json` (or `None` when `$HOME` isn't set); + /// `project` is the path to the per-project overrides. Public + /// so the test suite can exercise the merge without having to + /// mock the operator's real home directory. + pub fn load_merged_with_paths( + global: Option<&Path>, + project: &Path, + ) -> Self { + let mut s = match global { + Some(p) => Self::load_from(p), + None => Self::default(), + }; + let proj = Self::load_from(project); + s.apply_project_overrides(proj); + s + } + + /// Field-by-field overlay: project's Some values replace the + /// global ones (where "global" is `self`). Exposed separately + /// so tests can drive the merge from in-memory values without + /// touching the filesystem. + pub fn apply_project_overrides(&mut self, proj: Settings) { + if proj.models.fast.is_some() { + self.models.fast = proj.models.fast; + } + if proj.models.slow.is_some() { + self.models.slow = proj.models.slow; + } + if proj.models.main.is_some() { + self.models.main = proj.models.main; + } + if proj.models.todo.is_some() { + self.models.todo = proj.models.todo; + } + if proj.actions.allowed.is_some() { + self.actions.allowed = proj.actions.allowed; + } + } + + /// Warn on stderr for any token in `cli_extras` or + /// `self.actions.allowed` that isn't a recognised action type + /// (e.g. typo `bsah` for `bash`). The special CLI token `"all"` + /// is exempt. The warning includes a closest-match suggestion + /// when the distance is small. Returns the number of warnings. + pub fn warn_unknown_action_tokens(&self, cli_extras: &[String]) -> usize { + let known: BTreeSet<&str> = KNOWN_ACTION_TYPES.iter().copied().collect(); + let mut warned = 0usize; + let mut check = |tok: &str, origin: &str| { + if tok == "all" || known.contains(tok) { + return; + } + warned += 1; + let suggestion = closest_known_action(tok); + match suggestion { + Some(s) => eprintln!( + "settings: unknown action token '{tok}' ({origin}) — did you mean '{s}'? known: {}", + KNOWN_ACTION_TYPES.join(", ") + ), + None => eprintln!( + "settings: unknown action token '{tok}' ({origin}) — known: {}", + KNOWN_ACTION_TYPES.join(", ") + ), + } + }; + if let Some(list) = &self.actions.allowed { + for t in list { + check(t, "settings.json:actions.allowed"); + } + } + for t in cli_extras { + check(t, "--allow"); + } + warned + } + + /// Compute the effective action allowlist for this session. + /// + /// Semantics of `actions.allowed` in settings.json: + /// - `null` or key absent → fall back to `DEFAULT_ALLOWED_ACTIONS`. + /// - `[]` → empty, EVERY non-MCP action is denied. The + /// dispatcher enforces this — it does not collapse empty to + /// defaults. This is the operator's "lock it down" signal. + /// - `["read", ...]` → that exact set, no defaults merged in. + /// + /// `cli_extras` are the `--allow ACTION` flags. They are added + /// on top of whatever the settings resolved to. + /// + /// Unknown tokens (not in `KNOWN_ACTION_TYPES`, and not the + /// special CLI escape-hatch `"all"`) are DROPPED rather than + /// silently inserted — a dead entry in the allowlist serves no + /// purpose and masks the typo. `warn_unknown_action_tokens` + /// prints a warning for each dropped token; call it before (or + /// alongside) this function so the operator sees why their + /// flag didn't take. + /// + /// Special tokens in `cli_extras`: + /// - `"all"` expands to the full built-in set plus `bash` + /// (every action the dispatcher knows). Useful for one-off + /// runs where the operator wants a total escape hatch. + pub fn effective_allowed_actions( + &self, + cli_extras: &[String], + ) -> BTreeSet { + let known: BTreeSet<&str> = KNOWN_ACTION_TYPES.iter().copied().collect(); + let mut out: BTreeSet = match &self.actions.allowed { + Some(list) => list + .iter() + .filter(|t| known.contains(t.as_str())) + .cloned() + .collect(), + None => DEFAULT_ALLOWED_ACTIONS + .iter() + .map(|s| (*s).to_string()) + .collect(), + }; + for e in cli_extras { + if e == "all" { + for a in DEFAULT_ALLOWED_ACTIONS { + out.insert((*a).to_string()); + } + out.insert("bash".to_string()); + } else if known.contains(e.as_str()) { + out.insert(e.clone()); + } + // Unknown tokens are silently dropped here; the + // companion warn_unknown_action_tokens surfaces them. + } + out + } + /// Model id for a role, or `None` when settings.json did not /// specify one. pub fn model_for(&self, role: ModelRole) -> Option<&str> { @@ -117,6 +315,45 @@ impl Settings { } } +/// Cheap Levenshtein-like distance for the typo suggester. Only +/// called when a token isn't in the known set; return `Some(best)` +/// when the closest known action is within edit-distance 2. +fn closest_known_action(tok: &str) -> Option<&'static str> { + let mut best: Option<(&'static str, usize)> = None; + for cand in KNOWN_ACTION_TYPES { + let d = levenshtein(tok, cand); + if d <= 2 && best.map(|(_, bd)| d < bd).unwrap_or(true) { + best = Some((*cand, d)); + } + } + best.map(|(s, _)| s) +} + +fn levenshtein(a: &str, b: &str) -> usize { + let av: Vec = a.chars().collect(); + let bv: Vec = b.chars().collect(); + let (n, m) = (av.len(), bv.len()); + if n == 0 { + return m; + } + if m == 0 { + return n; + } + let mut prev: Vec = (0..=m).collect(); + let mut cur = vec![0usize; m + 1]; + for i in 1..=n { + cur[0] = i; + for j in 1..=m { + let cost = if av[i - 1] == bv[j - 1] { 0 } else { 1 }; + cur[j] = (prev[j] + 1) + .min(cur[j - 1] + 1) + .min(prev[j - 1] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[m] +} + /// Resolve a model for a role using the documented precedence: /// agent config → settings.json → Model::sonnet_4_6() fallback. pub fn pick_model(cfg_model: Option<&str>, role: ModelRole, settings: &Settings) -> Model { @@ -174,6 +411,7 @@ mod tests { slow: Some("claude-opus-4-7".into()), ..Default::default() }, + ..Default::default() }; assert_eq!( pick_model(Some("claude-sonnet-4-6"), ModelRole::Slow, &s).id, @@ -181,6 +419,210 @@ mod tests { ); } + #[test] + fn default_allowlist_excludes_bash() { + let s = Settings::default(); + let a = s.effective_allowed_actions(&[]); + assert!(a.contains("read")); + assert!(a.contains("grep")); + assert!(a.contains("find")); + assert!(a.contains("git")); + assert!(a.contains("edit")); + assert!( + !a.contains("bash"), + "bash should be off by default, got {a:?}" + ); + } + + #[test] + fn cli_allow_adds_bash() { + let s = Settings::default(); + let a = s.effective_allowed_actions(&["bash".into()]); + assert!(a.contains("bash")); + // All defaults still there. + assert!(a.contains("read")); + } + + #[test] + fn cli_allow_all_adds_everything() { + // `--allow all` expands to DEFAULT_ALLOWED_ACTIONS + bash, + // overriding even a narrow settings.json allowlist. Assert + // every element of the default set plus bash, so a future + // shrink of DEFAULT_ALLOWED_ACTIONS fails here instead of + // silently missing the "all" expansion. + let s = Settings { + actions: ActionSettings { + allowed: Some(vec!["read".into()]), + }, + ..Default::default() + }; + let a = s.effective_allowed_actions(&["all".into()]); + for expected in DEFAULT_ALLOWED_ACTIONS { + assert!( + a.contains(*expected), + "'all' should enable '{expected}', got {a:?}" + ); + } + assert!(a.contains("bash"), "'all' must enable bash, got {a:?}"); + } + + #[test] + fn settings_allowlist_replaces_default() { + // An explicit `["read","grep"]` kills the rest of the + // default set (not a union). + let s = Settings { + actions: ActionSettings { + allowed: Some(vec!["read".into(), "grep".into()]), + }, + ..Default::default() + }; + let a = s.effective_allowed_actions(&[]); + assert!(a.contains("read")); + assert!(a.contains("grep")); + assert!(!a.contains("git"), "git shouldn't leak from default: {a:?}"); + assert!(!a.contains("bash")); + } + + #[test] + fn project_only_allowlist_replaces_defaults() { + // A single project file containing a narrow allowlist must + // produce exactly that set — no defaults leaking through. + // Uses load_from directly rather than load_merged to avoid + // touching the operator's real ~/.kres/settings.json. + let dir = std::env::temp_dir().join(format!( + "kres-settings-proj-only-{}", + std::process::id() + )); + let proj = dir.join(".kres"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("settings.json"), + r#"{"actions":{"allowed":["read"]}}"#, + ) + .unwrap(); + let s = Settings::load_from(&proj.join("settings.json")); + let a = s.effective_allowed_actions(&[]); + assert_eq!( + a.iter().cloned().collect::>(), + vec!["read".to_string()] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn load_merged_project_overrides_global() { + // Global settings allow read+grep and set slow=opus; + // project narrows the allowlist to just read and overrides + // main=sonnet. Result: allowlist={read} (project wins), + // slow=opus (project didn't touch), main=sonnet (project + // wins). + let dir = std::env::temp_dir().join(format!( + "kres-settings-merge-real-{}", + std::process::id() + )); + let global_dir = dir.join("global"); + let proj_dir = dir.join("project").join(".kres"); + std::fs::create_dir_all(&global_dir).unwrap(); + std::fs::create_dir_all(&proj_dir).unwrap(); + let global_path = global_dir.join("settings.json"); + let proj_path = proj_dir.join("settings.json"); + std::fs::write( + &global_path, + r#"{"models":{"slow":"claude-opus-4-7"},"actions":{"allowed":["read","grep"]}}"#, + ) + .unwrap(); + std::fs::write( + &proj_path, + r#"{"models":{"main":"claude-sonnet-4-6"},"actions":{"allowed":["read"]}}"#, + ) + .unwrap(); + let s = + Settings::load_merged_with_paths(Some(&global_path), &proj_path); + assert_eq!(s.models.slow.as_deref(), Some("claude-opus-4-7")); + assert_eq!(s.models.main.as_deref(), Some("claude-sonnet-4-6")); + assert_eq!( + s.effective_allowed_actions(&[]) + .iter() + .cloned() + .collect::>(), + vec!["read".to_string()] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn load_merged_global_only_when_no_project() { + // Project file absent → result should be exactly the + // global settings. + let dir = std::env::temp_dir().join(format!( + "kres-settings-merge-global-only-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let global = dir.join("global.json"); + std::fs::write(&global, r#"{"actions":{"allowed":["read","grep"]}}"#) + .unwrap(); + let missing_project = dir.join("nope/.kres/settings.json"); + let s = Settings::load_merged_with_paths( + Some(&global), + &missing_project, + ); + let a: Vec = + s.effective_allowed_actions(&[]).iter().cloned().collect(); + assert_eq!(a, vec!["grep".to_string(), "read".to_string()]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn warn_unknown_action_tokens_flags_typos() { + let s = Settings { + actions: ActionSettings { + allowed: Some(vec!["read".into(), "fnid".into()]), + }, + ..Default::default() + }; + let n = s.warn_unknown_action_tokens(&["bsah".into(), "all".into()]); + // "read" is known; "all" is the CLI escape-hatch; "fnid" + // (typo for "find") and "bsah" (typo for "bash") warn. + assert_eq!(n, 2, "expected 2 warnings, got {n}"); + } + + #[test] + fn warn_unknown_action_tokens_silent_for_clean_input() { + let s = Settings { + actions: ActionSettings { + allowed: Some(vec!["read".into(), "bash".into()]), + }, + ..Default::default() + }; + let n = s.warn_unknown_action_tokens(&["grep".into(), "all".into()]); + assert_eq!(n, 0); + } + + #[test] + fn closest_known_action_suggests_within_edit_distance_two() { + assert_eq!(closest_known_action("bsah"), Some("bash")); + assert_eq!(closest_known_action("fnid"), Some("find")); + assert_eq!(closest_known_action("rad"), Some("read")); + // Completely unrelated token — no suggestion. + assert_eq!(closest_known_action("completely_wrong"), None); + } + + #[test] + fn explicit_empty_allowlist_stays_empty() { + // settings.json `{"actions":{"allowed":[]}}` means deny + // everything, not fall-back-to-defaults. Verify the + // resolved set is empty so the dispatcher sees the lockdown. + let s = Settings { + actions: ActionSettings { + allowed: Some(vec![]), + }, + ..Default::default() + }; + let a = s.effective_allowed_actions(&[]); + assert!(a.is_empty(), "explicit [] should stay empty, got {a:?}"); + } + #[test] fn empty_file_yields_defaults() { let dir = std::env::temp_dir().join(format!("kres-settings-empty-{}", std::process::id())); diff --git a/kres/src/main.rs b/kres/src/main.rs index cc0b3ae..7069471 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -182,6 +182,19 @@ struct ReplArgs { /// an explicit template wins over the variant picker. #[arg(long, default_value_t = false)] markdown: bool, + + /// Allow one additional non-MCP action type for this session. + /// Repeatable (`--allow bash --allow git`) or comma-separated + /// (`--allow bash,git`). Adds to whatever `actions.allowed` + /// resolved to from settings.json. The default allowlist is + /// grep/find/read/git/edit — `bash` is OFF by default because + /// operators report it becoming an escape hatch for things the + /// typed tools already cover. Example: `--allow bash` enables + /// the bash tool for compile+run in coding flows. The special + /// value `--allow all` enables every action type the dispatcher + /// knows (including bash). + #[arg(long, value_name = "ACTION", value_delimiter = ',')] + allow: Vec, } #[derive(Parser, Debug)] @@ -409,7 +422,8 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // When --slow is passed as a known tag (sonnet/opus) we also map // it to a model id, so `--slow sonnet` actually switches the // slow model. Explicit --slow-model still beats the tag mapping. - let mut settings = kres_repl::Settings::load_default(); + let mut settings = + kres_repl::Settings::load_merged(&args.workspace); // Only map the --slow tag to a model id when the operator // actually passed --slow. Without this gate the clap default // "sonnet" would unconditionally overwrite settings.models.slow @@ -634,6 +648,48 @@ async fn run_repl(args: ReplArgs) -> Result<()> { session = session.with_logger(lg.clone()); } let usage = Some(session.usage_tracker()); + // Compute the session's non-MCP action allowlist from settings + // layered with CLI --allow flags. Shared Arc so every MainAgent + // instance (currently one per kres) reads the same resolved set. + // Emit typo warnings up-front so an operator who wrote + // `--allow bsah` sees their mistake instead of silently keeping + // bash disabled. + let _ = settings.warn_unknown_action_tokens(&args.allow); + let allowed_actions: Arc> = + Arc::new(settings.effective_allowed_actions(&args.allow)); + // Print the allowlist banner only when a main agent is going + // to consult it. In --summary mode and any other shape where + // there's no main agent, the allowlist is dead data and + // printing it is just noise. + if main_agent.is_some() { + // The banner differentiates "bash off because default" + // from "bash off because the operator explicitly wrote a + // list that excludes it" — in the latter case pointing at + // `--allow bash` still works (CLI is additive) but the + // hint is worded to respect the deliberate choice rather + // than nudge them to undo it. + let bash_in_explicit_list = settings + .actions + .allowed + .as_ref() + .map(|l| l.iter().any(|s| s == "bash")) + .unwrap_or(false); + let bash_status = if allowed_actions.contains("bash") { + "ENABLED".to_string() + } else if settings.actions.allowed.is_some() && !bash_in_explicit_list { + "disabled by explicit allowlist in settings.json".to_string() + } else { + "disabled by default (add to settings.json or pass --allow bash to enable)".to_string() + }; + kres_core::async_eprintln!( + "actions: allowlist = [{}] (bash {bash_status})", + allowed_actions + .iter() + .cloned() + .collect::>() + .join(", ") + ); + } if let (Some(fc), Some(sc)) = (fast_agent.as_ref(), slow_agent.as_ref()) { let workspace = std::fs::canonicalize(&args.workspace).unwrap_or_else(|_| args.workspace.clone()); @@ -740,6 +796,7 @@ async fn run_repl(args: ReplArgs) -> Result<()> { mcp_servers: spawned_mcp.clone(), logger: logger.clone(), usage: usage.clone(), + allowed_actions: allowed_actions.clone(), }; kres_core::async_eprintln!( "main-agent: LLM-driven ({}), {} MCP server(s) routed", @@ -1008,6 +1065,30 @@ mod tests { assert_eq!(c.repl.slow.as_deref(), Some("opus")); } + #[test] + fn allow_flag_accepts_comma_separated() { + // value_delimiter = ',' on the --allow arg means both + // `--allow bash --allow git` and `--allow bash,git` parse + // into ["bash", "git"]. Repeatable-plus-delimited is what + // clap's conventional pattern expects, and this pins it so + // a future refactor can't silently drop the delimiter. + let c = Cli::try_parse_from([ + "kres", + "--allow", + "bash,git", + "--allow", + "edit", + ]) + .unwrap(); + assert_eq!(c.repl.allow, vec!["bash", "git", "edit"]); + } + + #[test] + fn allow_flag_defaults_to_empty() { + let c = Cli::try_parse_from(["kres"]).unwrap(); + assert!(c.repl.allow.is_empty()); + } + #[test] fn truncate_preserves_under_limit() { assert_eq!(truncate("abc", 10), "abc"); From 4a118d1f5d12725814145ce70dee88172ec2fe29 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 18:12:09 -0700 Subject: [PATCH 15/76] README: document coding mode, code_edits, and the action allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two major user-visible features landed since the README was last written and aren't documented anywhere operators would find them. - **Coding mode** (commits 68fc5a9, d5fa179, 8163c85) — a prompt like "write a reproducer for X" or "fix the bug in Y" is classified by the goal agent as coding mode instead of analysis. The slow agent emits a single call whose output is source code via two channels: `code_output` (full files written under `/code/`) and `code_edits` (surgical Edit- primitive-style `{file_path, old_string, new_string, replace_all}` records applied in-place atomically). Failed edits fold into the analysis trailer under `[FAILED]` so the next turn can correct them. Coding mode can also emit `bash` followups for compile/run verification — mentioning the `--allow bash` requirement makes the dependency explicit. - **Action allowlist** (commit 86aca4f) — the main agent's non- MCP tools are gated behind a session-wide allowlist. Document the three precedence levels (CLI `--allow`, per-project `/.kres/settings.json`, global `~/.kres/settings.json`), the defaults (grep/find/read/git/edit, bash OFF), the explicit empty-list "lock it down" signal, and the typo-detection behaviour. Include worked-example JSON blocks for both the permanent-enable and the tight-lockdown configs. CLI synopsis block at the bottom gains `--allow ACTION`. Signed-off-by: Chris Mason --- README.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 163251a..dbcd263 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,143 @@ At the end of a run you get a plain-text bug report via `/summary` You can point `--template PATH` at a custom file to override the shipped summariser prompt without rebuilding. +## Coding tasks: reproducers and in-place fixes + +Not every prompt is a review. Ask kres `--prompt 'write a +reproducer for the UAF in net/sched/cls_bpf.c'` or `--prompt 'fix +the missing frag-free in bnxt_xdp_redirect'` and the goal agent +classifies the task as **coding mode** instead of analysis. Coding +mode swaps out the review pipeline's lens fan-out and findings +consolidator for a single slow-agent call whose job is to produce +source code. Two output channels: + +- **`code_output`** — a list of `{path, content, purpose}` + records. Each entry is a full file body that the reaper writes + under `/code/` via tmp + rename. Use this for + fresh artifacts (reproducers, test harnesses, trigger programs, + scratch fixes that rewrite a whole file). + +- **`code_edits`** — a list of `{file_path, old_string, + new_string, replace_all}` records, same shape as Claude Code's + Edit primitive. The reaper applies each edit in order via + `kres_agents::tools::edit_file`: `old_string` must appear + exactly once in the current file contents (unless + `replace_all: true`), and the file is rewritten atomically via + tmp + rename (`kres-agents/src/tools.rs`). This is the + preferred channel for surgical one-line fixes — the + `old_string` anchor forces the slow agent to quote bytes from + the real file rather than reconstruct them from summary-level + descriptions. Each edit's result (replacement count for + success, verbatim error message for failure) is folded into + the task's analysis trailer under `Edits applied (N/M[, K + FAILED]):` so the next slow-agent turn can see which edits + landed and correct any that didn't. + +The slow-code prompt (`configs/prompts/slow-code-agent-coding.system.md`) +enforces two rules that matter in practice: the verbatim current +contents of the file being fixed must be in the gathered symbols +or context before any edit is emitted (a `read` followup is +requested and waited on otherwise — the slow agent is explicitly +told not to fix from memory), and a multi-edit batch applies in +emission order with each `old_string` matching the file state +AFTER prior edits in the same batch have landed. + +**Verification via `bash`** — the slow agent can emit a `bash` +followup (e.g. `cc -o repro repro.c && ./repro`, `make -C test`) +to build and run what it just wrote. The main agent executes it +from the workspace root, captures `[exit N]` + stdout + stderr, +and feeds the result back. This is the one flow where `bash` is +genuinely useful — but it is OFF by default (see "Action +allowlist" below) and must be explicitly enabled for the session. + +On a coding run you typically invoke kres with: + +``` +kres --prompt 'write a reproducer for the stack OOB in x_tables' \ + --allow bash \ + --results repro-run +``` + +Artifacts land in `/code/` (for `code_output`) and +in-place under `` (for `code_edits`). The ordinary +`report.md` + `findings.json` ledger continues to accumulate +narrative; coding tasks skip the findings-merger path since their +output is source files, not bug records. + +## Action allowlist + +The main agent's non-MCP tools are gated by a session-wide +allowlist. Defaults: `grep`, `find`, `read`, `git`, `edit`. +`bash` is **OFF by default** because operators report it being +reached for as a general escape hatch for things the typed tools +already cover (`bash sed` for range reads, `bash find` for +filename locates). An action whose `type` isn't in the allowlist +is rejected at dispatch time with a message naming the allowed +set and pointing at the two ways to fix it. + +**Three precedence levels:** + +1. `--allow ACTION` CLI flags — additive on top of whatever the + files resolved to. Repeatable (`--allow bash --allow git`) or + comma-separated (`--allow bash,git`). The special value + `--allow all` enables every action type the dispatcher knows. +2. Per-project `/.kres/settings.json` — overrides global + values field-by-field; an explicit allowlist replaces rather + than unions with the global one. +3. Global `~/.kres/settings.json` — the default resting place + for a per-user policy. + +**Example — enable bash for this session only:** + +``` +kres --allow bash --prompt 'reproduce the RDS UAF' +``` + +**Example — enable bash permanently in settings.json:** + +```json +{ + "actions": { + "allowed": ["grep", "find", "read", "git", "edit", "bash"] + } +} +``` + +**Example — deny every non-MCP action (tight lockdown, leaves +only MCP tools available to the main agent):** + +```json +{ + "actions": { + "allowed": [] + } +} +``` + +The empty array is the explicit "lock it down" signal — kres +dispatcher enforces it and does not fall back to defaults. +A missing or absent `actions.allowed` (i.e. `null` or the key +unset) is different: it means "use the built-in default list". + +**Typo detection** — tokens in `--allow` or `actions.allowed` +that aren't recognised action names produce a startup warning +with a closest-match suggestion (Levenshtein ≤ 2), e.g. +`settings: unknown action token 'bsah' (--allow) — did you mean +'bash'? known: grep, find, read, git, edit, bash, mcp`. Unknown +tokens are dropped rather than silently inserted, so a typo +never leaves a dead entry in the allowlist. + +**Startup banner** — when a main-agent config is resolved, kres +prints the effective allowlist on startup and distinguishes +"bash disabled by default" from "bash disabled by explicit +allowlist in settings.json". Both point at `--allow bash` as the +fix but the wording respects the source of the decision. + +MCP tools are gated separately (by mcp.json server registration, +not this allowlist) and don't enter the allowlist's dispatch +path. `--allow mcp` is a no-op and does not produce a typo +warning. + ## Review prompts kres can leverage the kernel review prompts for additional subsystem knowledge. @@ -445,7 +582,7 @@ kres [--fast-agent ...] [--slow TAG | --slow-agent ...] [--main-agent ...] [--results DIR] [--findings PATH] [--report PATH] [--todo PATH] [--prompt PROMPT] [--template PATH] [--turns N] [--gather-turns N] [--stop-grace-ms MS] [--stdio] - [--summary] + [--allow ACTION]... [--summary] ``` Interactive REPL commands: `/help`, `/tasks`, `/findings`, `/stop`, From e6678785fea9509cd6ba7f382a6a29286b1db74a Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Tue, 21 Apr 2026 18:22:56 -0700 Subject: [PATCH 16/76] README.md: add a NEWS section Signed-off-by: Chris Mason --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index dbcd263..f0e226a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ Kernel code RESearch agent — an LLM-driven multi-agent REPL for reviewing, auditing, and finding bugs in C source trees (the kernel is the primary target). +# NEWS + +April 21: I've updated the system prompts, you'll need to copy them into +~/.kres/prompts, or run setup.sh with --overwrite + +There's new support for writing patches as well, more details below. + ## Quick start 1. Build: From ad23595cb879cf96459808d72559a65fa64298de Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 03:25:48 -0700 Subject: [PATCH 17/76] agents: embed + override ~/.kres/system-prompts for every LLM system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on 3e9c3b4 (embed agent *.system.md files). That commit still read operator overrides from ~/.kres/prompts/, so a fresh install after an upgrade would keep reading stale files setup.sh wrote on the prior install — the exact shadow-by-stale- file problem the embed was meant to eliminate. This commit: 1. Moves the override directory to ~/.kres/system-prompts/ so leftover ~/.kres/prompts/*.system.md files from older installs are never consulted. 2. Extends the embed + override treatment to the bug-summary templates so /summary and `kres --summary` get the same "rebuild refreshes, optional override under system-prompts/" behaviour the agent prompts have. The split is consistent: `~/.kres/system-prompts/` holds operator overrides for compiled-in LLM system prompts (agent roles + bug-summary). `~/.kres/prompts/` continues to hold user-authored prompt TEMPLATES — review-template.md and any `-template.md` — which have nothing to do with LLM system prompts. Code changes ------------ - configs/*.json: the five shipped agent configs now reference `system_file: "system-prompts/.system.md"`. Path is resolved relative to the config file's directory, so at runtime it becomes ~/.kres/system-prompts/.system.md. - kres-agents/src/embedded_prompts.rs: the TABLE gains two entries — `bug-summary.md` and `bug-summary-markdown.md` — alongside the six existing agent prompts. New unit test `bug_summary_templates_are_present` pins both keys. Module docstring rewritten to make clear the module covers every LLM system prompt (not just agent prompts) and to document that only the review/``-template files stay on disk. - kres-repl/src/summary.rs: the previous direct `include_str!` for BUG_SUMMARY_TEMPLATE and BUG_SUMMARY_MARKDOWN_TEMPLATE is replaced by `bug_summary_template()` / `bug_summary_markdown_template()` functions that read from `kres_agents::embedded_prompts` (unwrap-expect because the table's test guarantees the keys). `default_template_path()` and `default_markdown_template_path()` now point at ~/.kres/system-prompts/ instead of ~/.kres/prompts/. The module and SummaryInputs docstrings updated to match. - kres-repl/src/session.rs: load_prompt_disk_then_embedded (the loader used for coding/generic slow-mode prompts) joins ~/.kres/system-prompts/. ReplConfig.template_path and the REPL's /summary helper both reference the new path. - setup.sh: prompts-install step now skips both `*.system.md` AND `bug-summary{,-markdown}.md` — everything with an embedded copy. Comment block spells out the split between embedded LLM system prompts and disk-based prompt-templates, and explains the leftover-file rationale for the new directory name. - kres/src/main.rs: --template docstring mentions the ~/.kres/system-prompts/bug-summary.md override path and the embedded fallback. Docs ---- - README NEWS entry notes the move and the leftover-file-ignored behaviour for both `*.system.md` and `bug-summary*.md`. - README "System prompts" section: load order now covers both prompt classes (agent configs and /summary), lists the embedded basenames explicitly, and explains that stale files under ~/.kres/prompts/ are safe to delete. - README "Summary output" bullet updated — bug-summary is no longer installed under ~/.kres/prompts/. - CLAUDE.md config-directory table renamed the override entry to `system-prompts/*.system.md` with a clearer description that the directory is empty by default. Tests ----- - Existing config.rs tests continue to build tmp configs with explicit sibling paths and do not depend on the directory- name rule. - New embedded_prompts test (`bug_summary_templates_are_present`) pins that the new entries land in the shared table. - Full workspace test run clean: 14 / 146 / 58 / 63 / 16 / 51 unit tests across the six crates. Signed-off-by: Chris Mason --- CLAUDE.md | 2 +- README.md | 67 +++++++++++++- configs/fast-code-agent.json | 2 +- configs/main-agent.json | 2 +- configs/slow-code-agent-opus.json | 2 +- configs/slow-code-agent-sonnet.json | 2 +- configs/todo-agent.json | 2 +- kres-agents/src/config.rs | 120 +++++++++++++++++++++++-- kres-agents/src/embedded_prompts.rs | 134 ++++++++++++++++++++++++++++ kres-agents/src/lib.rs | 1 + kres-repl/src/session.rs | 79 +++++++--------- kres-repl/src/summary.rs | 83 ++++++++++------- kres/src/main.rs | 6 +- setup.sh | 39 ++++++-- 14 files changed, 440 insertions(+), 101 deletions(-) create mode 100644 kres-agents/src/embedded_prompts.rs diff --git a/CLAUDE.md b/CLAUDE.md index 241455b..0af1b88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ this repo's `configs/` tree: | `todo-agent.json` | Todo-list-maintenance agent (tools-disabled variant) | | `mcp.json` | MCP server definitions (installed only when semcode-mcp is available) | | `settings.json` | Per-user defaults (today: per-role model ids). CLI flags `--fast-model`, `--slow-model`, `--main-model`, `--todo-model` override the matching role; a known `--slow ` (sonnet/opus) also overrides the slow model id unless `--slow-model` is given | -| `prompts/*.system.md` | System prompts referenced by each agent config | +| `system-prompts/*.system.md` | Optional operator overrides for agent system prompts. Default prompts are embedded in the kres binary (`kres-agents/src/embedded_prompts.rs`); a file at `~/.kres/system-prompts/` shadows the embedded copy. Empty by default | | `prompts/bug-summary.md` | Bug-report template for `/summary` and `kres --summary` | | `skills/*.md` | Domain knowledge files | diff --git a/README.md b/README.md index f0e226a..5b0151c 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ is the primary target). # NEWS -April 21: I've updated the system prompts, you'll need to copy them into -~/.kres/prompts, or run setup.sh with --overwrite +April 22: Agent system prompts are now embedded in the kres binary +— rebuilding kres refreshes them. `setup.sh` no longer copies +`*.system.md` anywhere. The override directory for a custom prompt +is `~/.kres/system-prompts/` (NOT `~/.kres/prompts/`); any stale +`.system.md` files left in `~/.kres/prompts/` from prior installs +are ignored. See "System prompts" below. -There's new support for writing patches as well, more details below. +April 21: There's new support for writing patches, more details below. ## Quick start @@ -253,7 +257,9 @@ At the end of a run you get a plain-text bug report via `/summary` subsequent `/summary` or `--summary` invocations know the original question), `/report.md`, and `/findings.json`. - Uses the fast agent with the `bug-summary.md` prompt template - (installed under `~/.kres/prompts/`) as a dedicated system prompt. + (embedded in the kres binary; overridable at + `~/.kres/system-prompts/bug-summary.md`) as a dedicated system + prompt. - Orders the resulting sections by `bug-severity` — `high` → `medium` → `low` → `latent` → `unknown` — with one section per bug, each led by `Subject:`, `bug-severity:`, and `bug-impact:` @@ -533,6 +539,59 @@ install step 2 drives the actual choice. Reintroducing a `"model"` line in one of the agent configs still takes effect and overrides settings.json for that agent only. +## System prompts + +Markdown files under `configs/prompts/` that carry LLM +system-prompt text are compiled into the kres binary via +`include_str!` (see `kres-agents/src/embedded_prompts.rs`). +`setup.sh` does NOT install them anywhere on disk. Rebuilding +kres is enough to pick up prompt changes; there is no +`--overwrite` dance to run after every repo pull. + +Covered prompts: + +- `*.system.md` — one per agent role (fast / slow / + slow-coding / slow-generic / main / todo). Referenced by the + shipped agent configs via `system_file: + "system-prompts/.system.md"`. +- `bug-summary.md` / `bug-summary-markdown.md` — the system + prompt for the `/summary` and `kres --summary` call. The + second variant is selected by `--markdown`. + +Load order used by `AgentConfig::load` (for agent prompts) and +`summary.rs::run_summary` (for the bug-summary templates): + +1. **Explicit path** (summary only): `--template PATH` wins + when set. +2. **Disk override**: `~/.kres/system-prompts/`. If + this file exists and is non-empty it is used verbatim. +3. **Embedded**: the compiled-in copy keyed by basename. +4. **Error** (agent configs only — summary silently falls + back to embedded): neither disk nor embedded → config load + fails with a message that names both paths. + +To customize a prompt for your own install, drop the edited file +at `~/.kres/system-prompts/`. The default install has +no files there; the embedded copies do all the work. + +Why `system-prompts/` and not `prompts/`? Older installs +populated `~/.kres/prompts/` directly from setup.sh (both +`*.system.md` and `bug-summary*.md`). Keeping the override in +the same directory would mean those leftover files shadow the +embedded defaults and produce stale behaviour after an upgrade. +A new directory name sidesteps that — a fresh kres reads only +the embedded prompts until the operator deliberately drops one +under the new path. Stale files under `~/.kres/prompts/` are +safe to delete (they are never consulted). + +Files that ARE still copied to disk by `setup.sh` (their job is +per-install prompt-template content, not an LLM system prompt) +continue to live in `~/.kres/prompts/`: + +- `review-template.md` and any `-template.md` — backs + `--prompt "word: extra details"`. Parsed into lenses via + `parse_prompt_file`. + ## Workspace layout ``` diff --git a/configs/fast-code-agent.json b/configs/fast-code-agent.json index 9f20f68..c86d56f 100644 --- a/configs/fast-code-agent.json +++ b/configs/fast-code-agent.json @@ -3,5 +3,5 @@ "max_tokens": 64000, "max_input_tokens": 800000, "rate_limit": 800000, - "system_file": "prompts/fast-code-agent.system.md" + "system_file": "system-prompts/fast-code-agent.system.md" } diff --git a/configs/main-agent.json b/configs/main-agent.json index dd8c3ba..e4d44eb 100644 --- a/configs/main-agent.json +++ b/configs/main-agent.json @@ -3,5 +3,5 @@ "max_tokens": 16384, "rate_limit": 800000, "concurrency": 3, - "system_file": "prompts/main-agent.system.md" + "system_file": "system-prompts/main-agent.system.md" } diff --git a/configs/slow-code-agent-opus.json b/configs/slow-code-agent-opus.json index a8bd72b..0fde6aa 100644 --- a/configs/slow-code-agent-opus.json +++ b/configs/slow-code-agent-opus.json @@ -3,5 +3,5 @@ "max_tokens": 128000, "max_input_tokens": 900000, "rate_limit": 800000, - "system_file": "prompts/slow-code-agent.system.md" + "system_file": "system-prompts/slow-code-agent.system.md" } diff --git a/configs/slow-code-agent-sonnet.json b/configs/slow-code-agent-sonnet.json index 520eb74..46447fc 100644 --- a/configs/slow-code-agent-sonnet.json +++ b/configs/slow-code-agent-sonnet.json @@ -3,5 +3,5 @@ "max_tokens": 64000, "max_input_tokens": 900000, "rate_limit": 800000, - "system_file": "prompts/slow-code-agent.system.md" + "system_file": "system-prompts/slow-code-agent.system.md" } diff --git a/configs/todo-agent.json b/configs/todo-agent.json index 957b851..2947189 100644 --- a/configs/todo-agent.json +++ b/configs/todo-agent.json @@ -2,5 +2,5 @@ "key": "@FAST_KEY@", "max_tokens": 32000, "rate_limit": 800000, - "system_file": "prompts/todo-agent.system.md" + "system_file": "system-prompts/todo-agent.system.md" } diff --git a/kres-agents/src/config.rs b/kres-agents/src/config.rs index c95dda1..776e765 100644 --- a/kres-agents/src/config.rs +++ b/kres-agents/src/config.rs @@ -92,6 +92,22 @@ impl AgentConfig { // Resolve and read `system_file` if present. It supersedes // any inline `system` — callers that want to override // should just drop the `system_file` field. + // + // Resolution order, in descending priority: + // 1. Disk file at the resolved path. An operator who + // wants to customize a prompt drops a file at the + // referenced path (typically `~/.kres/prompts/X.md`) + // and kres reads it. + // 2. Embedded prompt keyed by the file's basename. This + // is the normal path for stock installs — the + // `.system.md` files are compiled into the binary + // via `include_str!` (see `embedded_prompts` module), + // so a fresh install with no `~/.kres/prompts/` copy + // still runs. This replaces the previous "setup.sh + // must copy every prompt" workflow — operators no + // longer need `setup.sh --overwrite` when the repo's + // prompts change; rebuilding kres refreshes them. + // 3. Both missing → error, same as before. if let Some(ref sf) = cfg.system_file { let expanded = expand_tilde(sf); let resolved = if expanded.is_absolute() { @@ -103,10 +119,28 @@ impl AgentConfig { .unwrap_or_else(|| Path::new(".")) .join(expanded) }; - let body = std::fs::read_to_string(&resolved).map_err(|e| { - AgentError::Other(format!("system_file {}: {e}", resolved.display())) - })?; - cfg.system = Some(body); + let disk_read = std::fs::read_to_string(&resolved); + match disk_read { + Ok(body) => { + cfg.system = Some(body); + } + Err(disk_err) => { + let basename = resolved + .file_name() + .and_then(|o| o.to_str()) + .unwrap_or(""); + if let Some(embedded) = + crate::embedded_prompts::lookup(basename) + { + cfg.system = Some(embedded.to_string()); + } else { + return Err(AgentError::Other(format!( + "system_file {}: {disk_err} (no embedded fallback for basename '{basename}')", + resolved.display() + ))); + } + } + } } Ok(cfg) } @@ -250,11 +284,85 @@ mod tests { } #[test] - fn missing_system_file_errors() { - let p = write_tmp(r#"{"key": "sk-x", "system_file": "/tmp/does-not-exist-kres-test.md"}"#); + fn missing_system_file_without_embedded_match_errors() { + // The basename doesn't correspond to any embedded prompt + // (the `.system.md` table is agent-role specific) and the + // disk path is absent → both fallbacks fail and the caller + // gets a clear error. + let p = write_tmp( + r#"{"key": "sk-x", "system_file": "/tmp/does-not-exist-kres-test.md"}"#, + ); let e = AgentConfig::load(&p).unwrap_err(); let msg = format!("{e}"); assert!(msg.contains("system_file"), "got: {msg}"); + assert!( + msg.contains("no embedded fallback"), + "error should mention the embedded-fallback attempt, got: {msg}" + ); std::fs::remove_file(&p).ok(); } + + #[test] + fn missing_system_file_falls_back_to_embedded_prompt() { + // When the disk path is absent but the basename matches a + // known embedded prompt (the typical "stock install, no + // ~/.kres/prompts/" case), kres uses the compiled-in copy + // instead of erroring. This test targets `main-agent.system.md` + // because that name is guaranteed present in the embedded + // table. + let dir = std::env::temp_dir().join(format!( + "kres-sysfile-embedded-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + // Pointing at a nonexistent sibling file whose basename + // matches an embedded key. + let cfg_path = dir.join("agent.json"); + std::fs::write( + &cfg_path, + r#"{"key": "sk-x", "system_file": "prompts/main-agent.system.md"}"#, + ) + .unwrap(); + let c = AgentConfig::load(&cfg_path).unwrap(); + let body = c.system.expect("embedded fallback should populate system"); + assert!(!body.trim().is_empty(), "embedded prompt came back empty"); + // Sanity check — the main-agent system prompt mentions + // the action-type vocabulary. + assert!( + body.contains("action") || body.contains("grep"), + "body doesn't look like the main-agent prompt: {}", + &body[..body.len().min(200)] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn existing_disk_file_wins_over_embedded() { + // An operator's custom copy at the referenced path must + // take precedence over the embedded one — this is the + // override path. + let dir = std::env::temp_dir().join(format!( + "kres-sysfile-override-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + // Shadow the embedded main-agent prompt with a tiny + // operator-supplied one. Same basename, different body. + let prompts = dir.join("prompts"); + std::fs::create_dir_all(&prompts).unwrap(); + std::fs::write( + prompts.join("main-agent.system.md"), + "OPERATOR-OVERRIDE BODY", + ) + .unwrap(); + let cfg_path = dir.join("agent.json"); + std::fs::write( + &cfg_path, + r#"{"key": "sk-x", "system_file": "prompts/main-agent.system.md"}"#, + ) + .unwrap(); + let c = AgentConfig::load(&cfg_path).unwrap(); + assert_eq!(c.system.as_deref(), Some("OPERATOR-OVERRIDE BODY")); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs new file mode 100644 index 0000000..42dd8aa --- /dev/null +++ b/kres-agents/src/embedded_prompts.rs @@ -0,0 +1,134 @@ +//! LLM system prompts compiled into the kres binary. +//! +//! Markdown files under `configs/prompts/` that carry system-prompt +//! text for an LLM call (agent `*.system.md` prompts plus the +//! `bug-summary` templates for the `/summary` pipeline) are +//! included via `include_str!` at build time. A freshly-rebuilt +//! kres already knows the current prompts — no `setup.sh +//! --overwrite` dance is needed every time the repo's prompts +//! change. +//! +//! Disk still wins: an operator who wants to customize a prompt +//! drops a file at `~/.kres/system-prompts/` and kres +//! reads it ahead of the embedded copy. The embedded entry is the +//! fallback when the disk path is absent (the normal case — the +//! `system-prompts/` directory is empty by default). +//! +//! Excluded on purpose: the prompt TEMPLATES the operator wires +//! via `--prompt "word: extra"` (`review-template.md`, +//! `-template.md`). Those are user-authored content, not +//! LLM system prompts, and they continue to live on disk under +//! `~/.kres/prompts/`. + +/// Basename → verbatim prompt body. Keep the list aligned with +/// `configs/prompts/*.system.md` in the repo; a missing entry falls +/// through to "no embedded prompt" and the caller surfaces the disk +/// error as before. +const TABLE: &[(&str, &str)] = &[ + ( + "fast-code-agent.system.md", + include_str!("../../configs/prompts/fast-code-agent.system.md"), + ), + ( + "main-agent.system.md", + include_str!("../../configs/prompts/main-agent.system.md"), + ), + ( + "slow-code-agent.system.md", + include_str!("../../configs/prompts/slow-code-agent.system.md"), + ), + ( + "slow-code-agent-coding.system.md", + include_str!("../../configs/prompts/slow-code-agent-coding.system.md"), + ), + ( + "slow-code-agent-generic.system.md", + include_str!("../../configs/prompts/slow-code-agent-generic.system.md"), + ), + ( + "todo-agent.system.md", + include_str!("../../configs/prompts/todo-agent.system.md"), + ), + ( + "bug-summary.md", + include_str!("../../configs/prompts/bug-summary.md"), + ), + ( + "bug-summary-markdown.md", + include_str!("../../configs/prompts/bug-summary-markdown.md"), + ), +]; + +/// Return the embedded prompt body for a filename's basename, if +/// one is bundled in this build. `basename` is the final path +/// component with any directory prefix stripped (e.g. +/// `"main-agent.system.md"` for a config field +/// `"prompts/main-agent.system.md"`). +pub fn lookup(basename: &str) -> Option<&'static str> { + TABLE + .iter() + .find(|(k, _)| *k == basename) + .map(|(_, v)| *v) +} + +/// Every basename that has an embedded copy. Useful for logging / +/// diagnostics. +pub fn embedded_names() -> impl Iterator { + TABLE.iter().map(|(k, _)| *k) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_embedded_prompt_is_non_empty() { + for name in embedded_names() { + let body = lookup(name).expect("lookup must succeed for listed name"); + assert!( + !body.trim().is_empty(), + "embedded prompt {name} is empty" + ); + } + } + + #[test] + fn unknown_basename_returns_none() { + assert!(lookup("does-not-exist.system.md").is_none()); + } + + #[test] + fn lookup_is_exact_basename_match() { + // Callers pass the basename only; a full path with a + // directory prefix does not match. + assert!(lookup("prompts/main-agent.system.md").is_none()); + assert!(lookup("main-agent.system.md").is_some()); + } + + #[test] + fn all_expected_agent_prompts_are_present() { + for expected in [ + "fast-code-agent.system.md", + "main-agent.system.md", + "slow-code-agent.system.md", + "slow-code-agent-coding.system.md", + "slow-code-agent-generic.system.md", + "todo-agent.system.md", + ] { + assert!( + lookup(expected).is_some(), + "expected embedded prompt {expected} not found" + ); + } + } + + #[test] + fn bug_summary_templates_are_present() { + // bug-summary{,-markdown}.md back /summary and kres + // --summary; they are LLM system prompts for the + // summariser call, so they ride the same embed/override + // pipeline as the agent prompts. + assert!(lookup("bug-summary.md").is_some()); + assert!(lookup("bug-summary-markdown.md").is_some()); + } +} diff --git a/kres-agents/src/lib.rs b/kres-agents/src/lib.rs index 7dad1d1..bf85bcb 100644 --- a/kres-agents/src/lib.rs +++ b/kres-agents/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod consolidate; +pub mod embedded_prompts; pub mod error; pub mod fetcher; pub mod followup; diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 446e8f0..2b53de6 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -48,8 +48,8 @@ pub struct ReplConfig { pub results_dir: Option, /// Explicit `--template FILE` from the CLI. Passed through to /// SummaryInputs.template_path when /summary fires. When None - /// the summariser falls back to ~/.kres/prompts/bug-summary.md, then - /// to the compiled-in default (see kres_repl::summary). + /// the summariser falls back to ~/.kres/system-prompts/bug-summary.md, + /// then to the compiled-in default (see kres_repl::summary). pub template_path: Option, /// When true, skip the persistent status line (no DECSTBM scroll /// region). Useful for dumb terminals / pipes / finicky muxers. @@ -2216,9 +2216,10 @@ impl Session { // The REPL's /summary path keeps the plain-text default; // `--markdown` is a --summary-time flag, not a per-turn // one. An operator who wants markdown inside the REPL - // can point `/summary --template ~/.kres/prompts/bug- - // summary-markdown.md` at it, or use `kres --summary - // --markdown` post-hoc. + // can point `/summary --template + // ~/.kres/system-prompts/bug-summary-markdown.md` at it + // (after dropping the file there), or use `kres + // --summary --markdown` post-hoc. markdown: false, original_prompt, client: orc.fast_client.clone(), @@ -2647,59 +2648,45 @@ fn build_recent_context_preamble(entries: &[AccumulatedEntry], cap: usize) -> St out } -/// Compile-time fallback for the coding-mode slow-agent system prompt. -/// Used when `~/.kres/prompts/slow-code-agent-coding.system.md` is -/// absent (fresh install pre-setup.sh). Keeps coding tasks functional -/// without requiring a rebuild of the binary. -pub const SLOW_CODING_SYSTEM: &str = - include_str!("../../configs/prompts/slow-code-agent-coding.system.md"); - -/// Compile-time fallback for the generic-mode slow-agent system -/// prompt. The generic prompt is less opinionated than the -/// review/analysis prompt — it tells the slow agent to answer the -/// operator's question directly and use the full followup tool -/// surface (including bash) instead of forcing an "audit some code" -/// stance on every single-call task. -pub const SLOW_GENERIC_SYSTEM: &str = - include_str!("../../configs/prompts/slow-code-agent-generic.system.md"); - -/// Load the coding-mode system prompt: prefer the operator-editable -/// copy in `~/.kres/prompts/`, fall back to the compiled-in version. -/// Returns `None` only when $HOME is unset AND the file isn't readable -/// — the caller then leaves `slow_coding_system` as None and coding -/// tasks fall back to the analysis prompt with a warning (see -/// `pipeline::run_once_with_ctx`). -fn load_slow_coding_system() -> Option { +/// Load a `.system.md` prompt from disk-then-embedded, matching the +/// same two-step resolution `AgentConfig::load` uses for +/// `system_file`: an operator's `~/.kres/system-prompts/` +/// copy wins, otherwise the compiled-in entry from +/// `kres_agents::embedded_prompts` is used. Returns None only when +/// no embedded entry is bundled under this basename (in which case +/// the caller should surface a warning and fall back to its own +/// default — for coding/generic mode this means "use the analysis +/// prompt"; see `pipeline::run_once_with_ctx`). +/// +/// The override directory name is `system-prompts/` (not +/// `prompts/`) on purpose: before agent prompts were embedded in +/// the binary, setup.sh populated `~/.kres/prompts/*.system.md` +/// directly, and those leftover files would otherwise be read +/// ahead of the embedded defaults, producing stale behaviour +/// after an upgrade. Moving the override to a new directory name +/// means a fresh kres reads only the embedded prompts until the +/// operator deliberately drops a file under the new path. +fn load_prompt_disk_then_embedded(basename: &str) -> Option { if let Some(home) = dirs::home_dir() { let p = home .join(".kres") - .join("prompts") - .join("slow-code-agent-coding.system.md"); + .join("system-prompts") + .join(basename); if let Ok(s) = std::fs::read_to_string(&p) { if !s.trim().is_empty() { return Some(s); } } } - Some(SLOW_CODING_SYSTEM.to_string()) + kres_agents::embedded_prompts::lookup(basename).map(|s| s.to_string()) +} + +fn load_slow_coding_system() -> Option { + load_prompt_disk_then_embedded("slow-code-agent-coding.system.md") } -/// Load the generic-mode system prompt. Same resolution order as -/// `load_slow_coding_system`: `~/.kres/prompts/` wins when present, -/// otherwise the compiled-in SLOW_GENERIC_SYSTEM. fn load_slow_generic_system() -> Option { - if let Some(home) = dirs::home_dir() { - let p = home - .join(".kres") - .join("prompts") - .join("slow-code-agent-generic.system.md"); - if let Ok(s) = std::fs::read_to_string(&p) { - if !s.trim().is_empty() { - return Some(s); - } - } - } - Some(SLOW_GENERIC_SYSTEM.to_string()) + load_prompt_disk_then_embedded("slow-code-agent-generic.system.md") } /// Convenience: build an Orchestrator from paths to agent configs and diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index 371e313..0795d7c 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -2,13 +2,19 @@ //! a research run's report.md + findings.json. //! //! The communication rules live in `bug-summary.md`. The binary -//! carries a compile-time copy as a last-resort fallback, but callers -//! can (and normally do) point at an on-disk template so operators can -//! tune the prompt without rebuilding. Resolution order in -//! `run_summary`: +//! carries a compile-time copy via `kres_agents::embedded_prompts`, +//! and callers can (but normally don't need to) point at an on-disk +//! template so operators can tune the prompt without rebuilding. +//! Resolution order in `run_summary`: //! 1. `inputs.template_path` (explicit `--template FILE`), -//! 2. `~/.kres/prompts/bug-summary.md` (installed by setup.sh), -//! 3. the compiled-in `BUG_SUMMARY_TEMPLATE` constant. +//! 2. `~/.kres/system-prompts/bug-summary.md` (operator override +//! — empty by default; kres never installs this file), +//! 3. the compiled-in prompt from `embedded_prompts::lookup`. +//! +//! The override directory is `system-prompts/`, matching the agent +//! system-prompt override path. Old installs populated +//! `~/.kres/prompts/bug-summary.md` directly; those stale files +//! are ignored. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -20,31 +26,45 @@ use kres_agents::AgentConfig; use kres_core::findings::FindingsFile; use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; -/// Compile-time fallback copy of the plain-text bug-report template. -/// Used when neither `SummaryInputs.template_path` nor -/// `~/.kres/prompts/bug-summary.md` resolves to a readable file — -/// keeps a freshly built kres usable on a host that hasn't run -/// setup.sh yet. -pub const BUG_SUMMARY_TEMPLATE: &str = include_str!("../../configs/prompts/bug-summary.md"); +/// Compile-time fallback copy of the plain-text bug-report template, +/// sourced from `kres_agents::embedded_prompts`. Kept as a module- +/// level `fn()` rather than a `const` so the lookup stays in one +/// place and the table owns the source of truth. Panics only if the +/// embedded table is missing the key — which the table's own unit +/// test (`bug_summary_templates_are_present`) prevents. +pub fn bug_summary_template() -> &'static str { + kres_agents::embedded_prompts::lookup("bug-summary.md") + .expect("bug-summary.md missing from embedded_prompts table") +} /// Compile-time fallback for the markdown variant, selected by /// `--markdown`. Same content guidance, different output format /// (markdown with fenced code blocks instead of plain text). -pub const BUG_SUMMARY_MARKDOWN_TEMPLATE: &str = - include_str!("../../configs/prompts/bug-summary-markdown.md"); +pub fn bug_summary_markdown_template() -> &'static str { + kres_agents::embedded_prompts::lookup("bug-summary-markdown.md") + .expect("bug-summary-markdown.md missing from embedded_prompts table") +} -/// Default on-disk location for operator-editable templates. Populated -/// by setup.sh; run_summary reads this when no explicit template path -/// was given. Returns None when $HOME is unset. +/// Default on-disk override location for the plain-text template. +/// Empty by default; an operator who wants to shadow the embedded +/// prompt drops a file at `~/.kres/system-prompts/bug-summary.md`. +/// Returns None when $HOME is unset. pub fn default_template_path() -> Option { - dirs::home_dir().map(|h| h.join(".kres").join("prompts").join("bug-summary.md")) + dirs::home_dir().map(|h| { + h.join(".kres") + .join("system-prompts") + .join("bug-summary.md") + }) } -/// Default on-disk location for the markdown variant of the template. +/// Default on-disk override location for the markdown variant. /// `--markdown` selects this instead of the plain-text one. pub fn default_markdown_template_path() -> Option { - dirs::home_dir() - .map(|h| h.join(".kres").join("prompts").join("bug-summary-markdown.md")) + dirs::home_dir().map(|h| { + h.join(".kres") + .join("system-prompts") + .join("bug-summary-markdown.md") + }) } /// All the inputs to one summary run. Constructed once by either the @@ -55,9 +75,10 @@ pub struct SummaryInputs { pub output_path: PathBuf, /// Explicit override for the system prompt template. When Some, /// run_summary reads the file and errors if it cannot. When None, - /// `~/.kres/prompts/bug-summary.md` wins if it exists; else the - /// compiled-in fallback is used. When `markdown` is true the - /// markdown variant of each resolution step is tried instead. + /// `~/.kres/system-prompts/bug-summary.md` wins if it exists; + /// else the compiled-in fallback is used. When `markdown` is + /// true the markdown variant of each resolution step is tried + /// instead. pub template_path: Option, /// Select the markdown variant of the template + the `.md` output /// filename default. Ignored when `template_path` is set (the @@ -163,11 +184,11 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { }))?; // Resolve the system prompt template: explicit --template wins, - // then the on-disk operator-editable copy in ~/.kres/prompts/, else - // the compiled-in copy. `--markdown` picks the markdown variant at - // each hop (bug-summary-markdown.md, BUG_SUMMARY_MARKDOWN_TEMPLATE). - // Each hop logs its source so operators can tell which template - // shaped the report. + // then the on-disk operator override under + // ~/.kres/system-prompts/, else the compiled-in copy from + // kres_agents::embedded_prompts. `--markdown` picks the markdown + // variant at each hop. Each hop logs its source so operators can + // tell which template shaped the report. let (disk_default, fallback_text, fallback_label): ( Option, &'static str, @@ -175,13 +196,13 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { ) = if inputs.markdown { ( default_markdown_template_path(), - BUG_SUMMARY_MARKDOWN_TEMPLATE, + bug_summary_markdown_template(), "", ) } else { ( default_template_path(), - BUG_SUMMARY_TEMPLATE, + bug_summary_template(), "", ) }; diff --git a/kres/src/main.rs b/kres/src/main.rs index 7069471..c230fd6 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -170,8 +170,10 @@ struct ReplArgs { /// Override the bug-summary template path for --summary. Accepted by /// `/summary` too. When omitted, kres reads - /// ~/.kres/prompts/bug-summary.md (installed by setup.sh) and falls - /// back to the compiled-in copy if that's missing. + /// ~/.kres/system-prompts/bug-summary.md (the operator-override + /// path — empty by default) and falls back to the compiled-in + /// copy bundled in the binary (see + /// `kres-agents/src/embedded_prompts.rs`). #[arg(long, value_name = "FILE")] template: Option, diff --git a/setup.sh b/setup.sh index 08ed0fd..867c837 100755 --- a/setup.sh +++ b/setup.sh @@ -213,15 +213,42 @@ say "slow model: ${SLOW_MODEL}" say "model: ${MODEL}" echo "system prompts and agent configs:" -# All markdown under configs/prompts/ is shipped — agent system -# prompts, bug-summary.md, and any `*-template.md` prompt templates -# invoked via `--prompt word: extra`. Install every .md -# the source tree ships so adding a new prompt doesn't require a -# setup.sh edit. +# LLM system prompts are compiled into the kres binary via +# kres-agents::embedded_prompts and are deliberately NOT installed +# on disk. This covers the agent `*.system.md` prompts AND the +# `bug-summary{,-markdown}.md` templates used by `/summary` — +# both classes drive an LLM `system` field, just in different +# pipelines. Rebuilding kres refreshes the lot. +# +# An operator who wants to override any of those prompts drops a +# file at ~/.kres/system-prompts/ and the loader +# (AgentConfig::load for agent prompts, summary.rs for the bug- +# summary templates) reads it ahead of the embedded copy. +# +# The override directory is system-prompts/, not prompts/, on +# purpose: older installs populated ~/.kres/prompts/ directly +# (both *.system.md and bug-summary{,-markdown}.md), and those +# leftover files would otherwise shadow the embedded defaults +# and produce stale behaviour after an upgrade. The new +# directory name means a fresh kres reads only the embedded +# prompts until the operator deliberately drops one. +# +# `-template.md` files (review-template.md and any +# `-template.md` the operator added) DO install to +# ~/.kres/prompts/ — those are prompt-TEMPLATE content that the +# operator invokes via `--prompt "word: extra"`, not system +# prompts for an LLM call. mkdir -p "${DEST}/prompts" shopt -s nullglob for src in "${CONFIGS_SRC}/prompts"/*.md; do - install_file "$src" "${DEST}/prompts/$(basename "$src")" + case "$(basename "$src")" in + *.system.md | bug-summary.md | bug-summary-markdown.md) + # Embedded in the binary; skip. + ;; + *) + install_file "$src" "${DEST}/prompts/$(basename "$src")" + ;; + esac done shopt -u nullglob From 6a7ba38c3085d0bef4c29142623f573c6ec20abe Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 03:57:24 -0700 Subject: [PATCH 18/76] agents: slash-command templates via ~/.kres/commands (review/summary/summary-markdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the review template into a first-class slash command and introduces `~/.kres/commands/` as the override surface for every slash-command template. Three shipped commands — `review`, `summary`, `summary-markdown` — are embedded in the kres binary and can be replaced or added to by dropping a file at `~/.kres/commands/.md`. The review template was previously reached only via `--prompt "review: target"` through a path-based lookup under `~/.kres/prompts/review-template.md`. That path is kept as a back-compat fallback for operators with custom `-template.md` files, but the primary lookup is now `~/.kres/commands/.md` → embedded body. CLI: `--prompt "word: extra"` and `--prompt "/word extra"` ----------------------------------------------------------- `resolve_prompt_arg` now recognises both forms and resolves the command name through the new `user_commands::lookup`. Both examples are equivalent and produce the same composed prompt: kres --prompt 'review: fs/btrfs/ctree.c' kres --prompt '/review fs/btrfs/ctree.c' The splitter accepts `alphanum / - / _` for the command word; anything else falls through to inline-prompt. The `/word` variant uses the first whitespace run as the separator; the `word:` variant uses the first colon. New module: kres-agents::user_commands --------------------------------------- - TABLE keyed by command name (not filename) populated via include_str! for review-template.md, bug-summary.md, and bug-summary-markdown.md. - `lookup(name) -> Option` checks `~/.kres/commands/.md` on disk, falls back to the embedded table, else None. - Four unit tests: non-empty body for every entry, all three expected names present, unknown-name returns None, review body contains the `[investigate]` lens markers (catches a silent include_str! miswire). summary.rs now reads through user_commands ------------------------------------------ - `bug_summary_template()` and `bug_summary_markdown_template()` now return `String` and go through `user_commands::lookup` instead of `embedded_prompts::lookup`. Panic-message updated. - `default_template_path()` and `default_markdown_template_path()` return paths under `~/.kres/commands/` (was `~/.kres/system-prompts/` from the previous commit — the slash-command-templates override surface is distinct from the agent-system-prompts one). - Module docstring rewritten to describe the commands/summary[.md,-markdown.md] resolution path. - The run_summary caller now types `fallback_text` as `String` to match the new return type of the template fns. embedded_prompts table trimmed ------------------------------ `bug-summary.md` and `bug-summary-markdown.md` are removed from `kres-agents::embedded_prompts`; user_commands owns them now. The test that pinned their presence is dropped. embedded_prompts is back to its original scope (agent *.system.md only), which the module docstring now states. setup.sh -------- The prompts-install loop now skips `review-template.md` on top of the existing skips for `*.system.md` / `bug-summary{,-markdown}.md`. Comment rewritten to describe BOTH override directories (`system-prompts/` for agent prompts, `commands/` for slash commands) and the `-template.md` back-compat lane. Dep: `dirs` added to kres-agents' runtime deps so user_commands can resolve $HOME. Matches the workspace pin. Docs ---- - README NEWS entry rewritten for the `~/.kres/commands/` path and the two override directories. - README `--prompt 'review: ...'` section rewritten to show both invocation forms as equivalent and explain the user_commands::lookup resolution order. New section `## Slash-command templates` added under "System prompts" with an invocation table and a list of the three shipped commands. - README "Parallel lenses" section updated: customising the review template now means dropping `~/.kres/commands/review.md`, not editing `~/.kres/prompts/review-template.md`. - README "Summary output" bullet updated to point at `~/.kres/commands/summary.md` as the override. - CLAUDE.md config-directory table gains a `commands/.md` entry alongside the existing `system-prompts/` row. Full workspace test run is clean: 14 / 146 / 58 / 63 / 16 / 51 unit tests (+ new user_commands tests already counted in the kres-agents bucket). Signed-off-by: Chris Mason --- CLAUDE.md | 2 +- README.md | 215 +++++++++++++++++++--------- kres-agents/Cargo.toml | 1 + kres-agents/src/embedded_prompts.rs | 54 +++---- kres-agents/src/lib.rs | 1 + kres-agents/src/user_commands.rs | 112 +++++++++++++++ kres-repl/src/summary.rs | 85 ++++++----- kres/src/main.rs | 70 +++++++-- setup.sh | 51 ++++--- 9 files changed, 403 insertions(+), 188 deletions(-) create mode 100644 kres-agents/src/user_commands.rs diff --git a/CLAUDE.md b/CLAUDE.md index 0af1b88..df98d9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ this repo's `configs/` tree: | `mcp.json` | MCP server definitions (installed only when semcode-mcp is available) | | `settings.json` | Per-user defaults (today: per-role model ids). CLI flags `--fast-model`, `--slow-model`, `--main-model`, `--todo-model` override the matching role; a known `--slow ` (sonnet/opus) also overrides the slow model id unless `--slow-model` is given | | `system-prompts/*.system.md` | Optional operator overrides for agent system prompts. Default prompts are embedded in the kres binary (`kres-agents/src/embedded_prompts.rs`); a file at `~/.kres/system-prompts/` shadows the embedded copy. Empty by default | -| `prompts/bug-summary.md` | Bug-report template for `/summary` and `kres --summary` | +| `commands/.md` | Optional operator overrides (or additions) for slash-command templates. Shipped commands `review`, `summary`, `summary-markdown` are embedded in the kres binary (`kres-agents/src/user_commands.rs`). A file at `~/.kres/commands/.md` shadows the embedded copy; adding a new `.md` creates a `/name` command invocable via `--prompt "name: extra"` or `--prompt "/name extra"`. Empty by default | | `skills/*.md` | Domain knowledge files | Rate limiters are shared across agents that use the same API key string. diff --git a/README.md b/README.md index 5b0151c..d272b5b 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,21 @@ is the primary target). # NEWS -April 22: Agent system prompts are now embedded in the kres binary -— rebuilding kres refreshes them. `setup.sh` no longer copies -`*.system.md` anywhere. The override directory for a custom prompt -is `~/.kres/system-prompts/` (NOT `~/.kres/prompts/`); any stale -`.system.md` files left in `~/.kres/prompts/` from prior installs -are ignored. See "System prompts" below. +April 22: Agent system prompts and slash-command templates are +now embedded in the kres binary — rebuilding kres refreshes +them. `setup.sh` no longer copies `*.system.md`, `bug-summary*.md`, +or `review-template.md` anywhere. Two new override directories: + +- `~/.kres/system-prompts/.system.md` — operator override + for an agent system prompt. +- `~/.kres/commands/.md` — operator override for a slash- + command template (`/review`, `/summary`, `/summary-markdown`), + and the same lookup that backs `--prompt "word: extra"` and + the new `--prompt "/word extra"` form. + +Stale files left under `~/.kres/prompts/` from earlier installs +are ignored and safe to delete. See "System prompts" and +"Slash-command templates" below. April 21: There's new support for writing patches, more details below. @@ -73,17 +82,38 @@ April 21: There's new support for writing patches, more details below. kres --results review --prompt 'review: fs/btrfs/ctree.c' --turns 2 ``` -The `--prompt 'review: fs/btrfs/ctree.c'` form is a two-part prompt: -the token `review` names a template at -`~/.kres/prompts/review-template.md`, and the rest of the string is -the specific target. kres splices the target onto the front of the -template to produce a full prompt covering object lifetime, memory -safety, bounds checks, races, and general bugs in the named code. -Drop a new `-template.md` in `~/.kres/prompts/` to add your -own prompt templates. +The `--prompt 'review: fs/btrfs/ctree.c'` form is a two-part +prompt: the token `review` names the slash-command template +embedded in the kres binary (source: +`configs/prompts/review-template.md`), and the rest of the +string is the specific target. kres splices the target onto the +front of the template body to produce a full prompt covering +object lifetime, memory safety, bounds checks, races, and +general bugs in the named code. -Note: the template is invoked because the prompt has 'review:'. If you just -wrote 'review', the template would not be loaded. +Two equivalent forms — pick whichever reads better: + +``` +kres --prompt 'review: fs/btrfs/ctree.c' +kres --prompt '/review fs/btrfs/ctree.c' +``` + +Both resolve via `kres_agents::user_commands::lookup("review")`, +which prefers `~/.kres/commands/review.md` on disk (the operator +override path) and falls back to the embedded copy. Drop a file +at `~/.kres/commands/.md` to add a new command; use the +same `--prompt "name: extra"` or `--prompt "/name extra"` form +to invoke it. + +Legacy compatibility: `--prompt "word: extra"` still falls back +to `~/.kres/prompts/-template.md` when no matching +`~/.kres/commands/.md` exists and the name isn't one of +the embedded commands — operators with custom `-template.md` +files from before the refactor keep working. + +Note: the template is invoked because the prompt starts with +`review:` (or `/review`). A bare `review` or `review ` without +the separator is submitted verbatim. ### Parallel lenses inside `review-template.md` @@ -118,9 +148,12 @@ lens bullet fold into its `reason` field and become extra guidance the slow agent sees on that specific lens (see the sub-bullets under `object lifetime` and `memory allocations` in the template). -To add or remove angles for your own reviews, edit the bullets in -`~/.kres/prompts/review-template.md`, or drop a whole new -`-template.md` with its own lens set. +To add or remove angles for your own reviews, drop a customised +copy of the review template at `~/.kres/commands/review.md` — it +takes precedence over the embedded copy at load time. Dropping a +new `.md` alongside it (e.g. `~/.kres/commands/audit.md`) +adds a `/audit` slash-command you can invoke via +`--prompt "audit: target"` or `--prompt "/audit target"`. `--results review` tells kres where to keep the run's artifacts: `findings.json` (plus `findings-N.json` history snapshots), the @@ -256,10 +289,10 @@ At the end of a run you get a plain-text bug report via `/summary` - Picks up `/prompt.md` (saved on the first submit so subsequent `/summary` or `--summary` invocations know the original question), `/report.md`, and `/findings.json`. -- Uses the fast agent with the `bug-summary.md` prompt template +- Uses the fast agent with the `summary` slash-command template (embedded in the kres binary; overridable at - `~/.kres/system-prompts/bug-summary.md`) as a dedicated system - prompt. + `~/.kres/commands/summary.md`) as a dedicated system prompt. + `--markdown` selects the `summary-markdown` variant instead. - Orders the resulting sections by `bug-severity` — `high` → `medium` → `low` → `latent` → `unknown` — with one section per bug, each led by `Subject:`, `bug-severity:`, and `bug-impact:` @@ -541,56 +574,96 @@ settings.json for that agent only. ## System prompts -Markdown files under `configs/prompts/` that carry LLM -system-prompt text are compiled into the kres binary via -`include_str!` (see `kres-agents/src/embedded_prompts.rs`). -`setup.sh` does NOT install them anywhere on disk. Rebuilding -kres is enough to pick up prompt changes; there is no -`--overwrite` dance to run after every repo pull. - -Covered prompts: - -- `*.system.md` — one per agent role (fast / slow / - slow-coding / slow-generic / main / todo). Referenced by the - shipped agent configs via `system_file: - "system-prompts/.system.md"`. -- `bug-summary.md` / `bug-summary-markdown.md` — the system - prompt for the `/summary` and `kres --summary` call. The - second variant is selected by `--markdown`. - -Load order used by `AgentConfig::load` (for agent prompts) and -`summary.rs::run_summary` (for the bug-summary templates): - -1. **Explicit path** (summary only): `--template PATH` wins - when set. -2. **Disk override**: `~/.kres/system-prompts/`. If +Agent `*.system.md` prompts (fast / slow / slow-coding / +slow-generic / main / todo) are compiled into the kres binary +via `include_str!` (see `kres-agents/src/embedded_prompts.rs`). +`setup.sh` does NOT install them on disk. Rebuilding kres +refreshes them. + +The shipped agent configs under `configs/*.json` reference +`system_file: "system-prompts/.system.md"`; the path is +resolved relative to the config file's directory, so at runtime +it becomes `~/.kres/system-prompts/.system.md`. + +Load order used by `AgentConfig::load`: + +1. **Disk override**: `~/.kres/system-prompts/`. If this file exists and is non-empty it is used verbatim. -3. **Embedded**: the compiled-in copy keyed by basename. -4. **Error** (agent configs only — summary silently falls - back to embedded): neither disk nor embedded → config load - fails with a message that names both paths. - -To customize a prompt for your own install, drop the edited file -at `~/.kres/system-prompts/`. The default install has -no files there; the embedded copies do all the work. - -Why `system-prompts/` and not `prompts/`? Older installs -populated `~/.kres/prompts/` directly from setup.sh (both -`*.system.md` and `bug-summary*.md`). Keeping the override in -the same directory would mean those leftover files shadow the -embedded defaults and produce stale behaviour after an upgrade. -A new directory name sidesteps that — a fresh kres reads only -the embedded prompts until the operator deliberately drops one -under the new path. Stale files under `~/.kres/prompts/` are -safe to delete (they are never consulted). - -Files that ARE still copied to disk by `setup.sh` (their job is -per-install prompt-template content, not an LLM system prompt) -continue to live in `~/.kres/prompts/`: - -- `review-template.md` and any `-template.md` — backs - `--prompt "word: extra details"`. Parsed into lenses via - `parse_prompt_file`. +2. **Embedded**: the compiled-in copy keyed by basename. +3. **Error**: neither present → config load fails with a + message that names both paths. + +To customize an agent prompt for your own install, drop the +edited file at `~/.kres/system-prompts/`. The default +install has no files there; the embedded copies do all the work. + +Slash-command templates (`/review`, `/summary`, +`/summary-markdown`) live in a separate module +(`kres-agents/src/user_commands.rs`) with their own override +directory at `~/.kres/commands/` — see the next section. + +Why distinct directories? Older installs populated +`~/.kres/prompts/` directly from setup.sh (both `*.system.md` +and `bug-summary*.md`). Keeping the override in the same +directory would mean those leftover files shadow the embedded +defaults and produce stale behaviour after an upgrade. Two +fresh directory names sidestep that — a fresh kres reads only +the embedded defaults until the operator deliberately drops a +file under the new paths. Stale files under `~/.kres/prompts/` +are safe to delete (the slash-command loader still reads +`-template.md` from there as a back-compat fallback, but +will never find a filename matching one of the shipped embedded +commands there since setup.sh never writes those names to +`prompts/`). + +## Slash-command templates + +`review` / `summary` / `summary-markdown` are embedded +slash-command templates. Each has an `.md` body bundled in the +kres binary via `kres_agents::user_commands`, and an operator +can override or add commands by dropping a file at +`~/.kres/commands/.md`. Invocation paths: + +| Where | How to invoke `review` on `fs/btrfs/ctree.c` | +|-------|---------------------------------------------| +| CLI | `kres --prompt 'review: fs/btrfs/ctree.c'` | +| CLI | `kres --prompt '/review fs/btrfs/ctree.c'` (equivalent) | +| REPL | `/summary` — synthesises the accumulated run into `bug-report.txt` | + +The shipped three: + +- `review` — the parallel-lens review template (see the + "Parallel lenses" section above). Invocation prepends the + operator's target to the template body. +- `summary` — the plain-text bug-report system prompt that + `/summary` and `kres --summary` pass to the fast agent. +- `summary-markdown` — the markdown-output variant selected by + `--markdown`. + +Adding your own: drop `~/.kres/commands/audit.md` and run +`kres --prompt 'audit: net/...'` or `kres --prompt '/audit +net/...'`. No rebuild needed — the disk override path is +consulted on every invocation. + +Load order (identical for every command): + +1. `~/.kres/commands/.md` on disk (operator override). +2. Embedded body in `kres_agents::user_commands` (for the three + shipped commands). +3. Fallback to the legacy `~/.kres/prompts/-template.md` + lookup when neither of the above hit — preserves existing + custom templates from before this refactor. +4. Nothing matched → treat `"name: extra"` as a verbatim prompt. + +Files that setup.sh still copies to `~/.kres/prompts/`: any +operator-authored `-template.md` the user drops into +`configs/prompts/` that isn't shadowed by an embedded command +of the same root name. The shipped `review-template.md`, +`bug-summary.md`, and `bug-summary-markdown.md` are NOT copied +(they're embedded); `configs/prompts/-template.md` for +any other `` is copied verbatim so custom templates from +before the refactor keep working via the legacy +`~/.kres/prompts/-template.md` fallback path. ## Workspace layout diff --git a/kres-agents/Cargo.toml b/kres-agents/Cargo.toml index 34df0c4..e674ea3 100644 --- a/kres-agents/Cargo.toml +++ b/kres-agents/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +dirs = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs index 42dd8aa..33578ce 100644 --- a/kres-agents/src/embedded_prompts.rs +++ b/kres-agents/src/embedded_prompts.rs @@ -1,24 +1,25 @@ -//! LLM system prompts compiled into the kres binary. +//! Agent `*.system.md` prompts compiled into the kres binary. //! -//! Markdown files under `configs/prompts/` that carry system-prompt -//! text for an LLM call (agent `*.system.md` prompts plus the -//! `bug-summary` templates for the `/summary` pipeline) are -//! included via `include_str!` at build time. A freshly-rebuilt -//! kres already knows the current prompts — no `setup.sh -//! --overwrite` dance is needed every time the repo's prompts -//! change. +//! The agent-role system prompts (fast-code-agent, main-agent, +//! slow-code-agent, slow-code-agent-coding, slow-code-agent-generic, +//! todo-agent) are included via `include_str!` at build time. A +//! freshly-rebuilt kres already knows the current prompts — no +//! `setup.sh --overwrite` dance is needed every time the repo's +//! prompts change. //! -//! Disk still wins: an operator who wants to customize a prompt -//! drops a file at `~/.kres/system-prompts/` and kres -//! reads it ahead of the embedded copy. The embedded entry is the -//! fallback when the disk path is absent (the normal case — the -//! `system-prompts/` directory is empty by default). +//! Disk still wins: an operator who wants to customize an agent +//! prompt drops a file at `~/.kres/system-prompts/` and +//! kres reads it ahead of the embedded copy. The embedded entry is +//! the fallback when the disk path is absent (the normal case — +//! the `system-prompts/` directory is empty by default). //! -//! Excluded on purpose: the prompt TEMPLATES the operator wires -//! via `--prompt "word: extra"` (`review-template.md`, -//! `-template.md`). Those are user-authored content, not -//! LLM system prompts, and they continue to live on disk under -//! `~/.kres/prompts/`. +//! Not covered here: slash-command templates invoked via +//! `--prompt "word: extra"`, `--prompt "/word extra"`, or REPL +//! commands like `/review` / `/summary` / `/summary-markdown`. +//! Those live in the separate `kres_agents::user_commands` module +//! with their own override directory (`~/.kres/commands/`). The +//! split exists so agent-role prompts and operator-authored +//! prompt content keep distinct override surfaces. /// Basename → verbatim prompt body. Keep the list aligned with /// `configs/prompts/*.system.md` in the repo; a missing entry falls @@ -49,14 +50,6 @@ const TABLE: &[(&str, &str)] = &[ "todo-agent.system.md", include_str!("../../configs/prompts/todo-agent.system.md"), ), - ( - "bug-summary.md", - include_str!("../../configs/prompts/bug-summary.md"), - ), - ( - "bug-summary-markdown.md", - include_str!("../../configs/prompts/bug-summary-markdown.md"), - ), ]; /// Return the embedded prompt body for a filename's basename, if @@ -122,13 +115,4 @@ mod tests { } } - #[test] - fn bug_summary_templates_are_present() { - // bug-summary{,-markdown}.md back /summary and kres - // --summary; they are LLM system prompts for the - // summariser call, so they ride the same embed/override - // pipeline as the agent prompts. - assert!(lookup("bug-summary.md").is_some()); - assert!(lookup("bug-summary-markdown.md").is_some()); - } } diff --git a/kres-agents/src/lib.rs b/kres-agents/src/lib.rs index bf85bcb..0be651a 100644 --- a/kres-agents/src/lib.rs +++ b/kres-agents/src/lib.rs @@ -23,6 +23,7 @@ pub mod skills; pub mod symbol; pub mod todo_agent; pub mod tools; +pub mod user_commands; pub use config::{AgentConfig, AgentKind}; pub use consolidate::{consolidate_lenses, ConsolidatedTask, LensOutput}; diff --git a/kres-agents/src/user_commands.rs b/kres-agents/src/user_commands.rs new file mode 100644 index 0000000..46087d8 --- /dev/null +++ b/kres-agents/src/user_commands.rs @@ -0,0 +1,112 @@ +//! Slash-command templates: `/review`, `/summary`, `/summary-markdown`. +//! +//! Each name maps to an `.md` body that is compiled into the kres +//! binary via `include_str!`. An operator who wants to override a +//! command drops a file at `~/.kres/commands/.md` and kres +//! reads it ahead of the embedded copy. The default install has no +//! files under that directory; the embedded copies do all the work. +//! +//! Two code paths feed this table: +//! +//! - CLI `--prompt "word: extra"` and `--prompt "/word extra"` both +//! resolve via `lookup(word)` and prepend `extra` to the body. +//! - REPL slash commands `/review `, `/summary`, and +//! `/summary-markdown` read the body through the same lookup. +//! +//! Distinct from `kres_agents::embedded_prompts`: that module +//! bundles the agent `*.system.md` prompts (fast/slow/main/todo +//! system text), whose override directory is +//! `~/.kres/system-prompts/`. Slash-command templates are +//! operator-invoked prompts, not agent system prompts, so they +//! get their own directory (`~/.kres/commands/`) and override +//! path. + +/// Name → body. Keep aligned with the shipped files under +/// `configs/prompts/`. +const TABLE: &[(&str, &str)] = &[ + ( + "review", + include_str!("../../configs/prompts/review-template.md"), + ), + ( + "summary", + include_str!("../../configs/prompts/bug-summary.md"), + ), + ( + "summary-markdown", + include_str!("../../configs/prompts/bug-summary-markdown.md"), + ), +]; + +/// Return the body for `name` — disk override wins, then the +/// embedded default, else None. The disk override path is +/// `~/.kres/commands/.md`; non-existent and empty files +/// fall through to the embedded copy. +pub fn lookup(name: &str) -> Option { + if let Some(home) = dirs::home_dir() { + let p = home + .join(".kres") + .join("commands") + .join(format!("{name}.md")); + if let Ok(s) = std::fs::read_to_string(&p) { + if !s.trim().is_empty() { + return Some(s); + } + } + } + TABLE + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| (*v).to_string()) +} + +/// Every command name that has an embedded default. Consumers iterate +/// this for discovery (e.g. the `/help` listing or the CLI synopsis). +pub fn embedded_names() -> impl Iterator { + TABLE.iter().map(|(k, _)| *k) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_embedded_body_is_non_empty() { + for name in embedded_names() { + let body = lookup(name).unwrap_or_default(); + assert!( + !body.trim().is_empty(), + "command {name} body is empty" + ); + } + } + + #[test] + fn all_expected_commands_are_present() { + for expected in ["review", "summary", "summary-markdown"] { + assert!( + lookup(expected).is_some(), + "expected embedded command {expected} not found" + ); + } + } + + #[test] + fn unknown_name_returns_none() { + assert!(lookup("no-such-command").is_none()); + } + + #[test] + fn review_body_contains_template_markers() { + // Sanity check — the review template is the lens-bullet + // markdown file, which the prompt-file parser keys on + // `[investigate]` bullets. If the include_str stops pointing + // at the right file this would silently pick up a different + // body; asserting a literal marker catches that. + let body = lookup("review").unwrap(); + assert!( + body.contains("[investigate]"), + "review body missing [investigate] marker" + ); + } +} diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index 0795d7c..f70fa0f 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -1,20 +1,21 @@ //! /summary and `kres --summary` — render a plain-text bug report from //! a research run's report.md + findings.json. //! -//! The communication rules live in `bug-summary.md`. The binary -//! carries a compile-time copy via `kres_agents::embedded_prompts`, -//! and callers can (but normally don't need to) point at an on-disk -//! template so operators can tune the prompt without rebuilding. -//! Resolution order in `run_summary`: +//! The summariser is backed by the `/summary` (or +//! `/summary-markdown`) slash-command template. The binary carries +//! the embedded default via `kres_agents::user_commands`, and an +//! operator can shadow it by dropping a file under +//! `~/.kres/commands/`. Resolution order inside `run_summary`: //! 1. `inputs.template_path` (explicit `--template FILE`), -//! 2. `~/.kres/system-prompts/bug-summary.md` (operator override -//! — empty by default; kres never installs this file), -//! 3. the compiled-in prompt from `embedded_prompts::lookup`. +//! 2. `user_commands::lookup("summary")` / +//! `user_commands::lookup("summary-markdown")` — which +//! itself prefers `~/.kres/commands/.md` on disk and +//! falls back to the compiled-in default. //! -//! The override directory is `system-prompts/`, matching the agent -//! system-prompt override path. Old installs populated -//! `~/.kres/prompts/bug-summary.md` directly; those stale files -//! are ignored. +//! Stale files under `~/.kres/prompts/` or +//! `~/.kres/system-prompts/` are never consulted from this +//! module — `~/.kres/commands/` is the canonical override path +//! for slash-command templates. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -26,35 +27,32 @@ use kres_agents::AgentConfig; use kres_core::findings::FindingsFile; use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; -/// Compile-time fallback copy of the plain-text bug-report template, -/// sourced from `kres_agents::embedded_prompts`. Kept as a module- -/// level `fn()` rather than a `const` so the lookup stays in one -/// place and the table owns the source of truth. Panics only if the -/// embedded table is missing the key — which the table's own unit -/// test (`bug_summary_templates_are_present`) prevents. -pub fn bug_summary_template() -> &'static str { - kres_agents::embedded_prompts::lookup("bug-summary.md") - .expect("bug-summary.md missing from embedded_prompts table") +/// Resolve the plain-text bug-report template via the slash-command +/// lookup. Goes through `~/.kres/commands/summary.md` first (if the +/// operator wrote one) and falls back to the embedded default. +/// Panics only if the embedded table is missing the key — which +/// the module's own unit test +/// (`all_expected_commands_are_present`) prevents. +pub fn bug_summary_template() -> String { + kres_agents::user_commands::lookup("summary") + .expect("`summary` missing from user_commands table") } -/// Compile-time fallback for the markdown variant, selected by -/// `--markdown`. Same content guidance, different output format -/// (markdown with fenced code blocks instead of plain text). -pub fn bug_summary_markdown_template() -> &'static str { - kres_agents::embedded_prompts::lookup("bug-summary-markdown.md") - .expect("bug-summary-markdown.md missing from embedded_prompts table") +/// Resolve the markdown variant of the bug-report template. Same +/// two-step lookup (`~/.kres/commands/summary-markdown.md` → +/// embedded). +pub fn bug_summary_markdown_template() -> String { + kres_agents::user_commands::lookup("summary-markdown") + .expect("`summary-markdown` missing from user_commands table") } /// Default on-disk override location for the plain-text template. /// Empty by default; an operator who wants to shadow the embedded -/// prompt drops a file at `~/.kres/system-prompts/bug-summary.md`. -/// Returns None when $HOME is unset. +/// prompt drops a file at `~/.kres/commands/summary.md`. Returns +/// None when $HOME is unset. pub fn default_template_path() -> Option { - dirs::home_dir().map(|h| { - h.join(".kres") - .join("system-prompts") - .join("bug-summary.md") - }) + dirs::home_dir() + .map(|h| h.join(".kres").join("commands").join("summary.md")) } /// Default on-disk override location for the markdown variant. @@ -62,8 +60,8 @@ pub fn default_template_path() -> Option { pub fn default_markdown_template_path() -> Option { dirs::home_dir().map(|h| { h.join(".kres") - .join("system-prompts") - .join("bug-summary-markdown.md") + .join("commands") + .join("summary-markdown.md") }) } @@ -184,14 +182,15 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { }))?; // Resolve the system prompt template: explicit --template wins, - // then the on-disk operator override under - // ~/.kres/system-prompts/, else the compiled-in copy from - // kres_agents::embedded_prompts. `--markdown` picks the markdown - // variant at each hop. Each hop logs its source so operators can - // tell which template shaped the report. + // then the on-disk operator override under ~/.kres/commands/ + // (handled inside bug_summary_template / bug_summary_markdown_template + // via user_commands::lookup), else the compiled-in default. + // `--markdown` picks the markdown variant at each hop. Each hop + // logs its source so operators can tell which template shaped + // the report. let (disk_default, fallback_text, fallback_label): ( Option, - &'static str, + String, &'static str, ) = if inputs.markdown { ( @@ -216,7 +215,7 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { .with_context(|| format!("reading template {}", p.display()))?; (p.display().to_string(), text) } else { - (fallback_label.to_string(), fallback_text.to_string()) + (fallback_label.to_string(), fallback_text) }; eprintln!("summary: template = {}", template_src); diff --git a/kres/src/main.rs b/kres/src/main.rs index c230fd6..9f56c04 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -285,10 +285,21 @@ fn main() -> Result<()> { /// files, the skills directory, findings base, and mcp.json. /// Resolve the --prompt CLI argument into (source-description, body). /// +/// Recognised forms: /// 1. Path to an existing file → `(path.display(), file-contents)`. -/// 2. `"word: extra"` where `~/.kres/prompts/-template.md` -/// exists → `(template-path, extra + "\n\n" + template-body)`. -/// 3. Anything else → `("", raw)`. +/// 2. `"word: extra"` or `"/word extra"` naming a slash-command +/// template (embedded default plus optional override at +/// `~/.kres/commands/.md`) → `(source-label, extra + +/// "\n\n" + command-body)`. Both forms are equivalent: +/// `--prompt "review: fs/btrfs/ctree.c"` and +/// `--prompt "/review fs/btrfs/ctree.c"` produce the same +/// composed prompt. +/// 3. Legacy `~/.kres/prompts/-template.md` lookup — kept +/// as a back-compat fallback so operators with custom +/// `-template.md` files from before the slash-command +/// refactor keep working without edits. The new location +/// `~/.kres/commands/.md` is preferred. +/// 4. Anything else → `("", raw)`. fn resolve_prompt_arg(raw: &str) -> Result<(String, String)> { // Form 1: existing file path wins outright, including when the // name happens to contain a colon. @@ -298,35 +309,64 @@ fn resolve_prompt_arg(raw: &str) -> Result<(String, String)> { .with_context(|| format!("reading prompt file {}", as_path.display()))?; return Ok((as_path.display().to_string(), body)); } - // Form 2: "word: extra". The prefix must be a single bare word - // (alphanumerics, dash, underscore) so free-form questions that - // happen to contain colons don't false-match. - if let Some((head, rest)) = raw.split_once(':') { - let head_trim = head.trim(); - let is_word = !head_trim.is_empty() - && head_trim + + // Form 2: try to extract a command name and the trailing extra + // text from either "word: extra" or "/word extra". In both + // cases the name must be a single bare word (alphanumerics, + // dash, underscore) so free-form questions that happen to + // contain colons or start with a slash don't false-match. + let named: Option<(&str, &str)> = if let Some(after_slash) = raw.strip_prefix('/') { + // `/word extra` — split on the first whitespace run. + let (head, rest) = match after_slash.split_once(char::is_whitespace) { + Some((h, r)) => (h, r.trim()), + None => (after_slash, ""), + }; + Some((head, rest)) + } else if let Some((head, rest)) = raw.split_once(':') { + Some((head.trim(), rest.trim())) + } else { + None + }; + if let Some((head, rest)) = named { + let is_word = !head.is_empty() + && head .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); if is_word { + // Preferred: ~/.kres/commands/.md via + // user_commands::lookup (disk-first + embedded + // fallback). + if let Some(body) = kres_agents::user_commands::lookup(head) { + let composed = if rest.is_empty() { + body + } else { + format!("{rest}\n\n{body}") + }; + let src = format!("/{head} (user_commands)"); + return Ok((src, composed)); + } + // Legacy: ~/.kres/prompts/-template.md. Kept for + // operators whose custom templates predate the slash- + // command refactor. New templates should go under + // ~/.kres/commands/.md. if let Some(dir) = kres_dir() { let tmpl = dir .join("prompts") - .join(format!("{}-template.md", head_trim)); + .join(format!("{}-template.md", head)); if tmpl.exists() { let body = std::fs::read_to_string(&tmpl) .with_context(|| format!("reading template {}", tmpl.display()))?; - let extra = rest.trim(); - let composed = if extra.is_empty() { + let composed = if rest.is_empty() { body } else { - format!("{extra}\n\n{body}") + format!("{rest}\n\n{body}") }; return Ok((tmpl.display().to_string(), composed)); } } } } - // Form 3: inline prompt text. + // Form 4: inline prompt text. Ok(("".to_string(), raw.to_string())) } diff --git a/setup.sh b/setup.sh index 867c837..51e9b32 100755 --- a/setup.sh +++ b/setup.sh @@ -213,36 +213,41 @@ say "slow model: ${SLOW_MODEL}" say "model: ${MODEL}" echo "system prompts and agent configs:" -# LLM system prompts are compiled into the kres binary via -# kres-agents::embedded_prompts and are deliberately NOT installed -# on disk. This covers the agent `*.system.md` prompts AND the -# `bug-summary{,-markdown}.md` templates used by `/summary` — -# both classes drive an LLM `system` field, just in different -# pipelines. Rebuilding kres refreshes the lot. +# Every prompt/template the shipped kres binary uses is embedded +# via include_str!: agent `*.system.md` prompts go through +# kres-agents::embedded_prompts, slash-command templates +# (/review, /summary, /summary-markdown) go through +# kres-agents::user_commands. None of these files are installed +# on disk by default — rebuilding kres refreshes the lot. # -# An operator who wants to override any of those prompts drops a -# file at ~/.kres/system-prompts/ and the loader -# (AgentConfig::load for agent prompts, summary.rs for the bug- -# summary templates) reads it ahead of the embedded copy. +# Override directories (both empty on a fresh install, both +# honoured by the respective loaders when populated): # -# The override directory is system-prompts/, not prompts/, on -# purpose: older installs populated ~/.kres/prompts/ directly -# (both *.system.md and bug-summary{,-markdown}.md), and those -# leftover files would otherwise shadow the embedded defaults -# and produce stale behaviour after an upgrade. The new -# directory name means a fresh kres reads only the embedded -# prompts until the operator deliberately drops one. +# ~/.kres/system-prompts/.system.md +# → override an agent system prompt. AgentConfig::load reads +# this ahead of the embedded copy. # -# `-template.md` files (review-template.md and any -# `-template.md` the operator added) DO install to -# ~/.kres/prompts/ — those are prompt-TEMPLATE content that the -# operator invokes via `--prompt "word: extra"`, not system -# prompts for an LLM call. +# ~/.kres/commands/.md +# → override (or add) a slash-command template. Consulted by +# user_commands::lookup, which drives --prompt "word: extra", +# --prompt "/word extra", and the /review / /summary / +# /summary-markdown REPL paths. +# +# Both override directories are new; older installs populated +# ~/.kres/prompts/ directly. The rename prevents stale files +# from an earlier install shadowing embedded defaults after an +# upgrade — leftover files under ~/.kres/prompts/ are safe to +# delete. +# +# The only files that still install to ~/.kres/prompts/ are +# operator-authored `-template.md` templates used by the +# legacy --prompt "word: extra" lookup. Those are user content, +# not kres-shipped content. mkdir -p "${DEST}/prompts" shopt -s nullglob for src in "${CONFIGS_SRC}/prompts"/*.md; do case "$(basename "$src")" in - *.system.md | bug-summary.md | bug-summary-markdown.md) + *.system.md | bug-summary.md | bug-summary-markdown.md | review-template.md) # Embedded in the binary; skip. ;; *) From 47b7cb4570e6a03120875f7a876c6647f43ba7f4 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 04:35:04 -0700 Subject: [PATCH 19/76] repl: fix review findings on the slash-commands landing (6a7ba38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All issues identified in the follow-up review of 6a7ba38 addressed in one commit — behaviour fixes, test coverage, and doc sweeps. Behavior fixes -------------- - `/review ` and `/summary-markdown [FILE]` REPL commands added. The original 6a7ba38 landed the CLI equivalence (`--prompt "/review ..."` == `--prompt "review: ..."`) but left the REPL slash-command half of the user's ask unimplemented; typing `/review fs/btrfs/ctree.c` at the prompt hit `Command::Unknown("review")`. Now: * `/review ` composes the `review` template via `user_commands::compose` (same path the CLI uses) and submits the result as a new task. * `/summary-markdown [FILE]` behaves like `/summary` but selects the `summary-markdown` template and defaults the output filename to `bug-report.md`. * `cmd_summary` now takes a `markdown: bool` so both /summary and /summary-markdown share one code path. - `user_commands::lookup` rejects names that are not `[a-zA-Z0-9_-]+`. The CLI entry point already filtered that character set, but lookup is a public API and a future caller could hand it a path-traversal string like `"../etc/passwd"` which would otherwise resolve outside the commands directory. The guard lives in `is_valid_name` and applies to both `lookup` and the new `lookup_with_root` helper. - `user_commands::lookup_with_root(commands_dir, name)` split out so disk-override behaviour is testable without mocking `$HOME`. Mirrors the pattern used for `Settings::load_merged`. - `user_commands::compose(name, extra)` consolidates the "lookup + prepend extra" logic. Used by both `resolve_prompt_arg` in main.rs and the new `cmd_review` in session.rs so CLI and REPL flow through one implementation. Stale docs swept ---------------- Four docstrings pointed at the previous override path (`~/.kres/system-prompts/bug-summary{,-markdown}.md`); the code moved to `~/.kres/commands/.md` in 6a7ba38 but the comments were missed. Updated: - `kres/src/main.rs:~170` — `--template` docstring - `kres-repl/src/summary.rs:~76` — SummaryInputs.template_path - `kres-repl/src/session.rs:~51` — ReplConfig.template_path - `kres-repl/src/session.rs:~2230` — `/summary --template` hint in the REPL cmd_summary comment README + CLAUDE.md sweeps ------------------------- - README invocation table rebuilt. The previous three-row table had a header mismatch (titled "How to invoke review" but the third row was `/summary`). Replaced with a per-command matrix covering review / summary / summary-markdown across CLI and REPL. - "Parallel lenses" note reworded from "prompt starts with `review:`" to the precise "colon-terminated leading word" form, with an explicit example of what doesn't trigger. - REPL command list at the bottom of the README adds `/summary-markdown [FILE]` and `/review `. - CLAUDE.md REPL-commands table: `/summary` row expanded into three rows covering /summary, /summary-markdown, /review. Tests ----- - `user_commands` gains five tests (10 total in the module): * `disk_override_wins_over_embedded` — write a tmp file, assert lookup_with_root reads it instead of the embedded body. * `disk_override_empty_falls_through_to_embedded` — whitespace-only file doesn't brick the command. * `traversal_name_is_rejected` — path-traversal strings, empty / `.` / `..` all return None. * `compose_prepends_extra_to_body` — `compose("review", "X")` puts `X\n\n` at the front of the template body. * `compose_empty_extra_returns_bare_body` — empty extra text yields the body with no leading blank. * `compose_unknown_name_returns_none`. - `kres-repl::commands` gains two tests: * `parses_summary_markdown` — covers `/summary-markdown` with and without a filename arg. * `parses_review_with_target` and `review_without_target_is_unknown` — the target is required; missing it surfaces a helpful Unknown message. - `kres` main.rs gains four tests around `resolve_prompt_arg`: * `resolve_prompt_arg_word_colon_form_hits_user_commands` * `resolve_prompt_arg_slash_form_equivalent_to_colon_form` (pins the user's explicit CLI equivalence ask) * `resolve_prompt_arg_slash_unknown_command_falls_to_inline` * `resolve_prompt_arg_inline_colon_not_misparsed` Full workspace test run is clean: 156 kres-core, 58 kres-repl, 63 kres-llm, 16 kres-mcp, 54 kres unit tests pass. Signed-off-by: Chris Mason --- CLAUDE.md | 4 +- Cargo.lock | 1 + README.md | 37 +++++--- kres-agents/src/user_commands.rs | 151 ++++++++++++++++++++++++++++++- kres-repl/src/commands.rs | 58 ++++++++++++ kres-repl/src/session.rs | 129 ++++++++++++++++++-------- kres-repl/src/summary.rs | 109 +++++++++++----------- kres/src/main.rs | 94 ++++++++++++++----- 8 files changed, 447 insertions(+), 136 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df98d9e..b3e011a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,9 @@ Rate limiters are shared across agents that use the same API key string. | `/todo` | Show pending items (ready/blocked) + completed count | | `/todo --clear` | Clear all todo items | | `/cost` | Token usage by agent role and model | -| `/summary` | Main agent synthesizes accumulated findings | +| `/summary [FILE]` | Fast agent renders the run's report.md + findings.json into a bug report via the embedded `summary` slash-command template. Output defaults to `bug-report.txt` in the results dir | +| `/summary-markdown [FILE]` | Same as `/summary` but uses the `summary-markdown` template and defaults the filename to `bug-report.md` | +| `/review ` | Compose the embedded `review` slash-command template with `` and submit as a new task — CLI equivalent of `--prompt 'review: '` | | `/report ` | Write all findings to markdown file | | `/followup` | Show deferred items (identified but skipped when goal met) | | `/next` | Run next todo item | diff --git a/Cargo.lock b/Cargo.lock index b00a2a1..ec64565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,6 +797,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "dirs", "futures", "kres-core", "kres-llm", diff --git a/README.md b/README.md index d272b5b..aed5146 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,13 @@ to `~/.kres/prompts/-template.md` when no matching the embedded commands — operators with custom `-template.md` files from before the refactor keep working. -Note: the template is invoked because the prompt starts with -`review:` (or `/review`). A bare `review` or `review ` without -the separator is submitted verbatim. +Note: the template is invoked only when `review` appears as the +colon-terminated leading word (`"review:..."`) or as the +slash-prefixed leading word followed by whitespace +(`"/review ..."`). Free-form text that happens to contain those +character sequences elsewhere (e.g. `"what caused the review: ..."`) +is submitted verbatim — the split is anchored to the start of +the prompt. ### Parallel lenses inside `review-template.md` @@ -622,13 +626,22 @@ commands there since setup.sh never writes those names to slash-command templates. Each has an `.md` body bundled in the kres binary via `kres_agents::user_commands`, and an operator can override or add commands by dropping a file at -`~/.kres/commands/.md`. Invocation paths: +`~/.kres/commands/.md`. -| Where | How to invoke `review` on `fs/btrfs/ctree.c` | -|-------|---------------------------------------------| -| CLI | `kres --prompt 'review: fs/btrfs/ctree.c'` | -| CLI | `kres --prompt '/review fs/btrfs/ctree.c'` (equivalent) | -| REPL | `/summary` — synthesises the accumulated run into `bug-report.txt` | +Invocation paths (all three commands available in both places, +plus arbitrary operator commands dropped under +`~/.kres/commands/.md` are invocable the same way): + +| Command | CLI | REPL | +|--------------------|----------------------------------------------------|--------------------------------| +| `review` | `kres --prompt 'review: fs/btrfs/ctree.c'` or `kres --prompt '/review fs/btrfs/ctree.c'` | `/review fs/btrfs/ctree.c` | +| `summary` | `kres --summary --results DIR` | `/summary [filename]` | +| `summary-markdown` | `kres --summary --markdown --results DIR` | `/summary-markdown [filename]` | + +The `review:` and `/review` CLI forms compose the template body +with the trailing target; the `/review` REPL form does the same +composition through `user_commands::compose` and submits the +result as a new task. The shipped three: @@ -725,6 +738,6 @@ kres [--fast-agent ...] [--slow TAG | --slow-agent ...] [--main-agent ...] ``` Interactive REPL commands: `/help`, `/tasks`, `/findings`, `/stop`, -`/clear`, `/cost`, `/todo`, `/summary [FILE]`, `/report `, -`/load `, `/edit`, `/reply `, `/next`, `/continue`, -`/quit`. +`/clear`, `/cost`, `/todo`, `/summary [FILE]`, `/summary-markdown [FILE]`, +`/review `, `/report `, `/load `, `/edit`, +`/reply `, `/next`, `/continue`, `/quit`. diff --git a/kres-agents/src/user_commands.rs b/kres-agents/src/user_commands.rs index 46087d8..5d58385 100644 --- a/kres-agents/src/user_commands.rs +++ b/kres-agents/src/user_commands.rs @@ -42,12 +42,32 @@ const TABLE: &[(&str, &str)] = &[ /// embedded default, else None. The disk override path is /// `~/.kres/commands/.md`; non-existent and empty files /// fall through to the embedded copy. +/// +/// Names are restricted to `[a-zA-Z0-9_-]+` — a stray `/`, `\`, +/// or path segment would otherwise resolve to a file outside the +/// commands directory. Callers whose input is already restricted +/// (e.g. `kres/src/main.rs::resolve_prompt_arg` filters the same +/// character set) will never hit the reject path, but keeping the +/// guard here means a future caller that forgets to sanitize +/// still can't escape the directory. pub fn lookup(name: &str) -> Option { - if let Some(home) = dirs::home_dir() { - let p = home - .join(".kres") - .join("commands") - .join(format!("{name}.md")); + lookup_with_root(dirs::home_dir().map(|h| h.join(".kres").join("commands")), name) +} + +/// Testable core of `lookup`. `commands_dir` is the directory to +/// consult for disk overrides (pass `None` to skip the disk step +/// entirely — useful in tests that want to pin the embedded +/// fallback). `name` is validated against the same character set +/// as the public `lookup`. +pub fn lookup_with_root( + commands_dir: Option, + name: &str, +) -> Option { + if !is_valid_name(name) { + return None; + } + if let Some(dir) = commands_dir { + let p = dir.join(format!("{name}.md")); if let Ok(s) = std::fs::read_to_string(&p) { if !s.trim().is_empty() { return Some(s); @@ -60,12 +80,40 @@ pub fn lookup(name: &str) -> Option { .map(|(_, v)| (*v).to_string()) } +/// A command name is a non-empty run of ASCII alphanumerics, `-`, +/// and `_`. Anything else risks turning the lookup into a +/// directory-traversal primitive (`../etc/passwd`) or hitting +/// a file whose basename collides with the command name by +/// accident (a dotfile, a dot-segment, etc.). +fn is_valid_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + /// Every command name that has an embedded default. Consumers iterate /// this for discovery (e.g. the `/help` listing or the CLI synopsis). pub fn embedded_names() -> impl Iterator { TABLE.iter().map(|(k, _)| *k) } +/// Compose a full prompt from a command name and trailing extra +/// text — used by both the CLI (`--prompt "name: extra"` / +/// `--prompt "/name extra"`) and the REPL (`/review target` etc). +/// Returns `Some((source-label, body))` when `name` resolves to +/// a known command, `None` when the lookup fails. +pub fn compose(name: &str, extra: &str) -> Option<(String, String)> { + let body = lookup(name)?; + let extra = extra.trim(); + let composed = if extra.is_empty() { + body + } else { + format!("{extra}\n\n{body}") + }; + Some((format!("/{name} (user_commands)"), composed)) +} + #[cfg(test)] mod tests { use super::*; @@ -109,4 +157,97 @@ mod tests { "review body missing [investigate] marker" ); } + + #[test] + fn disk_override_wins_over_embedded() { + // Drop a file at /commands/review.md and assert + // lookup_with_root returns its contents, not the embedded + // review template. + let dir = std::env::temp_dir().join(format!( + "kres-cmd-override-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("review.md"), "OPERATOR REVIEW OVERRIDE") + .unwrap(); + let got = lookup_with_root(Some(dir.clone()), "review") + .expect("override should resolve"); + assert_eq!(got, "OPERATOR REVIEW OVERRIDE"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn disk_override_empty_falls_through_to_embedded() { + // An empty file at the override path should NOT shadow the + // embedded copy (consistent with the agent-prompt loader's + // behaviour) — returning empty prompt text would brick the + // command silently. + let dir = std::env::temp_dir().join(format!( + "kres-cmd-empty-override-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("review.md"), " \n\t\n").unwrap(); + let got = lookup_with_root(Some(dir.clone()), "review") + .expect("should fall through to embedded"); + assert!(got.contains("[investigate]"), "got {got:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn traversal_name_is_rejected() { + // `../foo` and friends must never be turned into a disk + // path by the loader. `lookup` returns None for any name + // that isn't ASCII alphanumeric + `-`/`_`. + assert!(lookup("../etc/passwd").is_none()); + assert!(lookup("a/b").is_none()); + assert!(lookup("").is_none()); + assert!(lookup(".").is_none()); + assert!(lookup("..").is_none()); + // lookup_with_root is equally strict — even when the + // caller hands it a seemingly safe commands_dir. + let dir = std::env::temp_dir(); + assert!(lookup_with_root(Some(dir), "../etc/passwd").is_none()); + } + + #[test] + fn compose_prepends_extra_to_body() { + let (src, body) = compose("review", "fs/btrfs/ctree.c").unwrap(); + assert!( + src.contains("review"), + "source label should name the command: {src}" + ); + assert!( + body.starts_with("fs/btrfs/ctree.c\n\n"), + "extra text must lead the composed body: {body:?}" + ); + assert!( + body.contains("[investigate]"), + "template body must follow: {body:?}" + ); + } + + #[test] + fn compose_empty_extra_returns_bare_body() { + // Unique job of this test: an empty `extra` argument must + // not prepend a blank `extra\n\n` block to the body. The + // body itself is already covered by + // review_body_contains_template_markers. + let (_, body_empty) = compose("review", "").unwrap(); + let (_, body_ws) = compose("review", " \n\t ").unwrap(); + let expected = lookup("review").unwrap(); + assert_eq!( + body_empty, expected, + "empty extra must yield the bare template body" + ); + assert_eq!( + body_ws, expected, + "whitespace-only extra (trimmed to empty) must behave the same" + ); + } + + #[test] + fn compose_unknown_name_returns_none() { + assert!(compose("no-such-command", "target").is_none()); + } } diff --git a/kres-repl/src/commands.rs b/kres-repl/src/commands.rs index 3cb694f..cf8fc5d 100644 --- a/kres-repl/src/commands.rs +++ b/kres-repl/src/commands.rs @@ -33,6 +33,17 @@ pub enum Command { /// to bug-report.txt, placed in the results directory when one /// was configured (else the current working directory). Summary { filename: Option }, + /// `/summary-markdown [filename]` — same as /summary but + /// selects the `summary-markdown` slash-command template for + /// the system prompt and defaults the filename to + /// `bug-report.md`. + SummaryMarkdown { filename: Option }, + /// `/review ` — submit a prompt equivalent to + /// `--prompt "review: "`. Composes the `review` + /// slash-command template (disk override at + /// ~/.kres/commands/review.md wins over the embedded copy) + /// with the trailing target text and queues it as a new task. + Review { target: String }, /// `/extract [--dir DIR] [--report F] [--todo F] [--findings F]` /// — copy session artifacts to operator-chosen destinations. Extract { @@ -93,6 +104,19 @@ pub fn parse_command(line: &str) -> Command { "summary" => Command::Summary { filename: rest.split_whitespace().next().map(|s| s.to_string()), }, + "summary-markdown" => Command::SummaryMarkdown { + filename: rest.split_whitespace().next().map(|s| s.to_string()), + }, + "review" => { + let target = rest.trim().to_string(); + if target.is_empty() { + Command::Unknown( + "review (expected: /review , e.g. /review fs/btrfs/ctree.c)".into(), + ) + } else { + Command::Review { target } + } + } "extract" => Command::Extract { dir: flag_value(rest, "--dir").map(|s| s.to_string()), report: flag_value(rest, "--report").map(|s| s.to_string()), @@ -199,6 +223,40 @@ mod tests { assert_eq!(parse_command("/deferred"), Command::Followup); } + #[test] + fn parses_summary_markdown() { + assert_eq!( + parse_command("/summary-markdown"), + Command::SummaryMarkdown { filename: None } + ); + assert_eq!( + parse_command("/summary-markdown report.md"), + Command::SummaryMarkdown { + filename: Some("report.md".into()) + } + ); + } + + #[test] + fn parses_review_with_target() { + match parse_command("/review fs/btrfs/ctree.c") { + Command::Review { target } => { + assert_eq!(target, "fs/btrfs/ctree.c"); + } + other => panic!("expected Review, got {other:?}"), + } + } + + #[test] + fn review_without_target_is_unknown() { + match parse_command("/review") { + Command::Unknown(s) => { + assert!(s.starts_with("review"), "got {s}"); + } + other => panic!("expected Unknown, got {other:?}"), + } + } + #[test] fn parses_summary() { assert_eq!( diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 2b53de6..c889106 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -48,8 +48,10 @@ pub struct ReplConfig { pub results_dir: Option, /// Explicit `--template FILE` from the CLI. Passed through to /// SummaryInputs.template_path when /summary fires. When None - /// the summariser falls back to ~/.kres/system-prompts/bug-summary.md, - /// then to the compiled-in default (see kres_repl::summary). + /// the summariser falls back to ~/.kres/commands/summary.md (or + /// summary-markdown.md with `/summary-markdown`), then to the + /// compiled-in default (see kres_repl::summary and + /// kres_agents::user_commands). pub template_path: Option, /// When true, skip the persistent status line (no DECSTBM scroll /// region). Useful for dumb terminals / pipes / finicky muxers. @@ -1383,7 +1385,11 @@ impl Session { } } Command::Followup => self.cmd_followup().await, - Command::Summary { filename } => self.cmd_summary(filename).await, + Command::Summary { filename } => self.cmd_summary(filename, false).await, + Command::SummaryMarkdown { filename } => { + self.cmd_summary(filename, true).await + } + Command::Review { target } => self.cmd_review(target).await, Command::Extract { dir, report, @@ -1482,7 +1488,7 @@ impl Session { ); } else { kres_core::async_eprintln!("--turns: rendering bug-report.txt before exit"); - self.cmd_summary(None).await; + self.cmd_summary(None, false).await; } } @@ -2157,8 +2163,11 @@ impl Session { /// The calls the main agent to generate a smart /// synthesis; kres currently produces a deterministic concatenation /// (TODO: add an LLM synthesiser once the summariser agent config - /// is defined). Matches the shape of `/summary` at. - async fn cmd_summary(&self, filename: Option) { + /// is defined). Matches the shape of `/summary`; pass + /// `markdown=true` (via the `/summary-markdown` slash command) to + /// select the markdown-variant template and default the output + /// filename to `bug-report.md` instead of `bug-report.txt`. + async fn cmd_summary(&self, filename: Option, markdown: bool) { let Some(orc) = self.orchestrator.as_ref() else { async_println( "/summary: orchestrator not configured (need --fast-agent and --slow-agent)", @@ -2187,8 +2196,18 @@ impl Session { .results_dir .clone() .or_else(|| report_path.parent().map(std::path::Path::to_path_buf)); + // /summary-markdown defaults the filename to bug-report.md + // instead of bug-report.txt so the operator's explicit + // filename wins (--summary --markdown behaves the same at + // the CLI). + let default_name: Option<&str> = match filename.as_deref() { + Some(_) => None, + None if markdown => Some("bug-report.md"), + None => None, + }; + let effective_name = filename.as_deref().or(default_name); let output_path = - crate::summary::default_output_path(output_dir.as_deref(), filename.as_deref()); + crate::summary::default_output_path(output_dir.as_deref(), effective_name); let findings_path = self.cfg.findings_base.clone(); // Original prompt resolution: in-memory initial_prompt wins // (it's the literal --prompt FILE or first submission). If @@ -2213,29 +2232,57 @@ impl Session { findings_path, output_path: output_path.clone(), template_path: self.cfg.template_path.clone(), - // The REPL's /summary path keeps the plain-text default; - // `--markdown` is a --summary-time flag, not a per-turn - // one. An operator who wants markdown inside the REPL - // can point `/summary --template - // ~/.kres/system-prompts/bug-summary-markdown.md` at it - // (after dropping the file there), or use `kres - // --summary --markdown` post-hoc. - markdown: false, + // `/summary` uses the plain-text template, + // `/summary-markdown` flips this flag so the summariser + // reads `summary-markdown` from the user_commands table + // (with the operator's + // ~/.kres/commands/summary-markdown.md as an override). + markdown, original_prompt, client: orc.fast_client.clone(), model: orc.fast_model.clone(), max_tokens: orc.fast_max_tokens, max_input_tokens: orc.fast_max_input_tokens, }; + let label = if markdown { "/summary-markdown" } else { "/summary" }; async_println(format!( - "/summary: rendering bug report to {}", + "{label}: rendering bug report to {}", output_path.display() )); if let Err(e) = crate::summary::run_summary(inputs).await { - async_println(format!("/summary: {e}")); + async_println(format!("{label}: {e}")); } } + /// `/review ` — compose the embedded `review` + /// slash-command template with the operator's target string + /// and submit the result as a new prompt. Uses the same + /// user_commands::compose path as `--prompt "review: ..."` so + /// the CLI and REPL share exactly one code path for the + /// review flow. + async fn cmd_review(&self, target: String) { + let target = target.trim(); + if target.is_empty() { + async_println( + "/review: expected a target, e.g. /review fs/btrfs/ctree.c", + ); + return; + } + let Some((src, body)) = + kres_agents::user_commands::compose("review", target) + else { + async_println( + "/review: `review` template missing from the embedded table — this is a build bug", + ); + return; + }; + async_println(format!( + "/review: composed prompt from {src} ({} chars)", + body.len() + )); + self.submit_prompt(body).await; + } + /// `/extract [--dir D] [--report F] [--todo F] [--findings F]` — /// copy session artifacts to operator-chosen destinations. Matches async fn cmd_extract( @@ -3199,27 +3246,33 @@ fn print_banner() { fn print_help() { println!("commands:"); - println!(" /help, /? show this help"); - println!(" /tasks, /task list running tasks"); - println!(" /findings summarise findings"); - println!(" /stop cancel running tasks"); - println!(" /clear stop tasks, reset findings + todo + accumulated context"); - println!(" /compact summarise accumulated context into one short entry"); - println!(" /cost show API token usage"); - println!(" /todo show the todo list"); - println!(" /report write findings report (markdown)"); - println!(" /load submit a file's contents as the next prompt"); - println!(" /edit open $EDITOR on a scratch file, submit on save"); - println!(" /followup list items deferred by goal/--turns"); - println!(" /summary [FILE] render report.md+findings.json into a plain-text bug report (default bug-report.txt)"); - println!(" /extract ... copy artifacts (--dir, --report, --todo, --findings)"); - println!(" /done N remove the N'th pending todo"); - println!(" /todo --clear drop every todo item"); - println!(" /reply prepend last analysis to new text, submit"); - println!(" /next dispatch the next pending todo item as a prompt"); - println!(" /continue dispatch every unblocked pending todo"); - println!(" /quit, /exit leave the REPL"); - println!(" submit as a prompt"); + println!(" /help, /? show this help"); + println!(" /tasks, /task list running tasks"); + println!(" /findings summarise findings"); + println!(" /stop cancel running tasks"); + println!(" /clear stop tasks, reset findings + todo + accumulated context"); + println!(" /compact summarise accumulated context into one short entry"); + println!(" /cost show API token usage"); + println!(" /todo show the todo list"); + println!(" /report write findings report (markdown)"); + println!(" /load submit a file's contents as the next prompt"); + println!(" /edit open $EDITOR on a scratch file, submit on save"); + println!(" /followup list items deferred by goal/--turns"); + println!(" /review compose the embedded `review` template with and submit"); + println!(" /summary [FILE] render report.md+findings.json into a plain-text bug report (default bug-report.txt)"); + println!(" /summary-markdown [FILE] render the markdown variant (default bug-report.md)"); + println!(" /extract ... copy artifacts (--dir, --report, --todo, --findings)"); + println!(" /done N remove the N'th pending todo"); + println!(" /todo --clear drop every todo item"); + println!(" /reply prepend last analysis to new text, submit"); + println!(" /next dispatch the next pending todo item as a prompt"); + println!(" /continue dispatch every unblocked pending todo"); + println!(" /quit, /exit leave the REPL"); + println!(" submit as a prompt"); + println!(); + println!( + "override slash-command templates by dropping a file at ~/.kres/commands/.md" + ); } fn truncate(s: &str, n: usize) -> String { diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index f70fa0f..cdd71a7 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -27,25 +27,6 @@ use kres_agents::AgentConfig; use kres_core::findings::FindingsFile; use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; -/// Resolve the plain-text bug-report template via the slash-command -/// lookup. Goes through `~/.kres/commands/summary.md` first (if the -/// operator wrote one) and falls back to the embedded default. -/// Panics only if the embedded table is missing the key — which -/// the module's own unit test -/// (`all_expected_commands_are_present`) prevents. -pub fn bug_summary_template() -> String { - kres_agents::user_commands::lookup("summary") - .expect("`summary` missing from user_commands table") -} - -/// Resolve the markdown variant of the bug-report template. Same -/// two-step lookup (`~/.kres/commands/summary-markdown.md` → -/// embedded). -pub fn bug_summary_markdown_template() -> String { - kres_agents::user_commands::lookup("summary-markdown") - .expect("`summary-markdown` missing from user_commands table") -} - /// Default on-disk override location for the plain-text template. /// Empty by default; an operator who wants to shadow the embedded /// prompt drops a file at `~/.kres/commands/summary.md`. Returns @@ -73,10 +54,10 @@ pub struct SummaryInputs { pub output_path: PathBuf, /// Explicit override for the system prompt template. When Some, /// run_summary reads the file and errors if it cannot. When None, - /// `~/.kres/system-prompts/bug-summary.md` wins if it exists; - /// else the compiled-in fallback is used. When `markdown` is - /// true the markdown variant of each resolution step is tried - /// instead. + /// `~/.kres/commands/summary.md` wins if it exists; else the + /// compiled-in `summary` body from `kres_agents::user_commands` + /// is used. When `markdown` is true the `summary-markdown` + /// variant is selected at each hop instead. pub template_path: Option, /// Select the markdown variant of the template + the `.md` output /// filename default. Ignored when `template_path` is set (the @@ -125,6 +106,47 @@ pub fn load_fast_for_summary( Ok((client, fast_model, max_tokens, fast_cfg.max_input_tokens)) } +/// Resolve the summariser's system-prompt template to a +/// (source-label, body) pair. Each disk path is read at most once; +/// the embedded fallback skips disk entirely. Precedence: +/// 1. `inputs.template_path` (explicit `--template FILE`). +/// 2. `~/.kres/commands/.md` when the file exists (the +/// operator override path; `` is `summary` or +/// `summary-markdown` depending on `inputs.markdown`). +/// 3. The compiled-in body from `kres_agents::user_commands`. +fn resolve_template(inputs: &SummaryInputs) -> Result<(String, String)> { + if let Some(ref p) = inputs.template_path { + let text = std::fs::read_to_string(p) + .with_context(|| format!("reading template {}", p.display()))?; + return Ok((p.display().to_string(), text)); + } + let (disk_default, fallback_label, fallback_name) = if inputs.markdown { + ( + default_markdown_template_path(), + "", + "summary-markdown", + ) + } else { + ( + default_template_path(), + "", + "summary", + ) + }; + if let Some(p) = disk_default.filter(|p| p.exists()) { + let text = std::fs::read_to_string(&p) + .with_context(|| format!("reading template {}", p.display()))?; + return Ok((p.display().to_string(), text)); + } + let body = kres_agents::user_commands::lookup(fallback_name) + .ok_or_else(|| { + anyhow!( + "embedded `{fallback_name}` template missing from user_commands — build bug" + ) + })?; + Ok((fallback_label.to_string(), body)) +} + /// Run the summary pipeline. Reads report.md (required) and /// findings.json (optional — missing is a warning, not an error), /// sends them to the fast agent with the embedded template as the @@ -182,41 +204,12 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { }))?; // Resolve the system prompt template: explicit --template wins, - // then the on-disk operator override under ~/.kres/commands/ - // (handled inside bug_summary_template / bug_summary_markdown_template - // via user_commands::lookup), else the compiled-in default. - // `--markdown` picks the markdown variant at each hop. Each hop - // logs its source so operators can tell which template shaped - // the report. - let (disk_default, fallback_text, fallback_label): ( - Option, - String, - &'static str, - ) = if inputs.markdown { - ( - default_markdown_template_path(), - bug_summary_markdown_template(), - "", - ) - } else { - ( - default_template_path(), - bug_summary_template(), - "", - ) - }; - let (template_src, template_text): (String, String) = if let Some(ref p) = inputs.template_path - { - let text = std::fs::read_to_string(p) - .with_context(|| format!("reading template {}", p.display()))?; - (p.display().to_string(), text) - } else if let Some(p) = disk_default.filter(|p| p.exists()) { - let text = std::fs::read_to_string(&p) - .with_context(|| format!("reading template {}", p.display()))?; - (p.display().to_string(), text) - } else { - (fallback_label.to_string(), fallback_text) - }; + // else the on-disk operator override under ~/.kres/commands/, + // else the compiled-in default. `--markdown` picks the markdown + // variant at each hop. We read each file at most once — the + // per-hop log line names the source so operators can tell which + // template actually shaped the report. + let (template_src, template_text) = resolve_template(&inputs)?; eprintln!("summary: template = {}", template_src); let mut cfg = CallConfig::defaults_for(inputs.model.clone()) diff --git a/kres/src/main.rs b/kres/src/main.rs index 9f56c04..5086ccc 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -168,12 +168,13 @@ struct ReplArgs { #[arg(long, default_value_t = false)] summary: bool, - /// Override the bug-summary template path for --summary. Accepted by - /// `/summary` too. When omitted, kres reads - /// ~/.kres/system-prompts/bug-summary.md (the operator-override - /// path — empty by default) and falls back to the compiled-in - /// copy bundled in the binary (see - /// `kres-agents/src/embedded_prompts.rs`). + /// Override the bug-summary template path for --summary. Accepted + /// by `/summary` too. When omitted, kres reads + /// ~/.kres/commands/summary.md (the operator-override path — + /// empty by default) and falls back to the compiled-in copy + /// bundled in the binary (see + /// `kres-agents/src/user_commands.rs`). `--markdown` selects + /// `summary-markdown.md` at each hop instead. #[arg(long, value_name = "FILE")] template: Option, @@ -328,27 +329,24 @@ fn resolve_prompt_arg(raw: &str) -> Result<(String, String)> { None }; if let Some((head, rest)) = named { + // Preferred: ~/.kres/commands/.md via user_commands + // (disk-first + embedded fallback + name-validation). The + // validation inside compose covers the same character set + // we'd enforce here, so there's no need to pre-filter. + if let Some((src, composed)) = + kres_agents::user_commands::compose(head, rest) + { + return Ok((src, composed)); + } + // Legacy: ~/.kres/prompts/-template.md. Kept for + // operators whose custom templates predate the slash- + // command refactor. New templates should go under + // ~/.kres/commands/.md. let is_word = !head.is_empty() && head .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); if is_word { - // Preferred: ~/.kres/commands/.md via - // user_commands::lookup (disk-first + embedded - // fallback). - if let Some(body) = kres_agents::user_commands::lookup(head) { - let composed = if rest.is_empty() { - body - } else { - format!("{rest}\n\n{body}") - }; - let src = format!("/{head} (user_commands)"); - return Ok((src, composed)); - } - // Legacy: ~/.kres/prompts/-template.md. Kept for - // operators whose custom templates predate the slash- - // command refactor. New templates should go under - // ~/.kres/commands/.md. if let Some(dir) = kres_dir() { let tmpl = dir .join("prompts") @@ -1131,6 +1129,58 @@ mod tests { assert!(c.repl.allow.is_empty()); } + #[test] + fn resolve_prompt_arg_word_colon_form_hits_user_commands() { + // --prompt "review: target" resolves via user_commands to the + // embedded review template with the target prepended. + let (src, body) = resolve_prompt_arg("review: fs/btrfs/ctree.c") + .expect("review: form should resolve"); + assert!(src.contains("review"), "source label: {src}"); + assert!( + body.starts_with("fs/btrfs/ctree.c\n\n"), + "target must lead body: {body:?}" + ); + assert!(body.contains("[investigate]"), "review body missing"); + } + + #[test] + fn resolve_prompt_arg_slash_form_equivalent_to_colon_form() { + // The whole point of the CLI slash-form: --prompt "/review X" + // must produce the same composed prompt as --prompt "review: X". + let (_, colon_body) = + resolve_prompt_arg("review: fs/btrfs/ctree.c").unwrap(); + let (_, slash_body) = + resolve_prompt_arg("/review fs/btrfs/ctree.c").unwrap(); + assert_eq!( + colon_body, slash_body, + "slash form and colon form must compose identically" + ); + } + + #[test] + fn resolve_prompt_arg_slash_unknown_command_falls_to_inline() { + // A slash prefix with no matching command and no legacy + // template on disk must pass through as verbatim prompt + // text — NOT error, NOT be silently dropped. + let (src, body) = + resolve_prompt_arg("/no-such-cmd hello world").unwrap(); + assert_eq!(src, ""); + assert_eq!(body, "/no-such-cmd hello world"); + } + + #[test] + fn resolve_prompt_arg_inline_colon_not_misparsed() { + // A free-form question that happens to contain a colon but + // doesn't start with a command word must stay inline — this + // is the "question like 'when did btrfs: land?' shouldn't + // look up a btrfs template" case. + let (src, body) = + resolve_prompt_arg("why does func() return: unusual values?") + .unwrap(); + assert_eq!(src, ""); + assert!(body.contains("unusual values")); + } + #[test] fn truncate_preserves_under_limit() { assert_eq!(truncate("abc", 10), "abc"); From 842241886a9e1ea356ad6530eb6156c6e26b6fc4 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 04:59:18 -0700 Subject: [PATCH 20/76] README.md: news update Signed-off-by: Chris Mason --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index aed5146..d32419c 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,7 @@ is the primary target). April 22: Agent system prompts and slash-command templates are now embedded in the kres binary — rebuilding kres refreshes them. `setup.sh` no longer copies `*.system.md`, `bug-summary*.md`, -or `review-template.md` anywhere. Two new override directories: - -- `~/.kres/system-prompts/.system.md` — operator override - for an agent system prompt. -- `~/.kres/commands/.md` — operator override for a slash- - command template (`/review`, `/summary`, `/summary-markdown`), - and the same lookup that backs `--prompt "word: extra"` and - the new `--prompt "/word extra"` form. +or `review-template.md` anywhere. Stale files left under `~/.kres/prompts/` from earlier installs are ignored and safe to delete. See "System prompts" and From e1bb7526fa0189c77c97e27ad581a3a425e097ff Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 05:57:05 -0700 Subject: [PATCH 21/76] review-template: target is above, not below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `user_commands::compose()` builds the review prompt as `format!("{extra}\n\n{body}")` (kres-agents/src/user_commands.rs:112), so the operator-supplied target lands *above* the template body. The opening line said "the target below", so when an operator invoked `/review` with no target but an extra paragraph that itself ended "... the target below", the fast agent found nothing below, emitted only a type:question asking for the target, the main agent replied with prose (no ``), gather returned empty, and every lens slow agent emitted "SCOPE CHECK FAILED — no source code was provided" (linux.net session b686e702-f6af). Fix by rewriting the opening paragraph to describe what actually precedes it (file path, function name, commit ref, diff, or snippet) so the fast agent latches onto the prepended target. Signed-off-by: Chris Mason --- configs/prompts/review-template.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/configs/prompts/review-template.md b/configs/prompts/review-template.md index 8f502df..2069de6 100644 --- a/configs/prompts/review-template.md +++ b/configs/prompts/review-template.md @@ -1,4 +1,7 @@ -We're doing a deep security and bug analysis of the target below. +The block above this paragraph is the review target — a file path, +function name, commit ref (e.g. `HEAD`), diff, or code snippet +supplied by the operator. We're doing a deep security and bug +analysis of that target. Focus on just the target itself and the supporting code it calls, without expanding out into the rest of the kernel. Pay special From 12d99ed371666b87a0887841fc29f8d2ba5295f0 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 06:03:53 -0700 Subject: [PATCH 22/76] pipeline: stop the fast loop from spinning on unanswerable work Two parity gaps between `run_once_with_ctx` and the lens-mode `gather` let the fast loop burn rounds without making progress: 1. `gather` ignored `skill_reads`. `run_once_with_ctx` has `apply_skill_reads(&mut live_skills, &parsed.skill_reads)` at the top of the parse-handling block so any skill file the fast agent requests mid-loop reaches the slow agent. `gather` instead threaded `self.skills` straight through, so a lens-mode fast agent that emitted `skill_reads` got its request silently dropped and the lens slow agents saw the pre-gather skills payload. 2. Neither loop bailed when every followup had `kind == "question"`. Question-type followups are clarifications for the operator, not fetchable data; `MainAgent::fetch` turns them into prose with no `` (kres-agents/src/main_agent.rs:260-269), so `fetched.symbols` / `fetched.context` come back empty and the loop re-asks. Session b686e702-f6af on linux.net spent every fast round and every lens call on a prompt with no target. Fix by cloning `self.skills` into a `live_skills` binding inside `gather`, calling `apply_skill_reads` on it, returning it from `gather`, and passing it into the lens `CodePrompt`; and by breaking out of both the `run_once_with_ctx` and `gather` loops when `parsed.followups.iter().all(|f| f.kind == "question")`. Signed-off-by: Chris Mason --- kres-agents/src/pipeline.rs | 115 ++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 6 deletions(-) diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index 03d5d52..f63de10 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -399,6 +399,21 @@ impl Orchestrator { continue; } + // If every followup is a type:question (clarification + // for the operator), the main agent can't fetch data + // for it — looping would just re-ask the same question. + // Break out to the slow agent, which can surface the + // question to the operator via its own followups. + if !parsed.followups.is_empty() + && parsed.followups.iter().all(|f| f.kind == "question") + { + kres_core::async_eprintln!( + "[fast round {}] only type:question followups — breaking to slow", + fast_rounds + ); + break; + } + // Summarise the followups so operators can see what the // fast agent asked the main agent to fetch on their // behalf. @@ -665,7 +680,8 @@ impl Orchestrator { let prompt: &str = composed.as_str(); // Gather once via fast+main (same loop as run_once, up to the // point where we'd call the slow agent). - let (symbols, context, fast_rounds) = self.gather(prompt, shutdown).await?; + let (symbols, context, fast_rounds, live_skills) = + self.gather(prompt, shutdown).await?; // Fan out N slow-agent calls in parallel. let mut futures = Vec::with_capacity(lenses.len()); @@ -700,8 +716,11 @@ impl Orchestrator { .with_previous_findings(&trimmed_prev) .with_parallel_lenses(¶llel_lenses); // §cache: include skills in the lens prompt — same - // rationale as the single slow call above. - if let Some(sk) = &self.skills { + // rationale as the single slow call above. Use the + // post-gather `live_skills` so any skill files the + // fast agent pulled in mid-gather reach the lens slow + // agents too. + if let Some(sk) = &live_skills { lens_cp = lens_cp.with_skills(sk); } let (lens_prefix, lens_suffix) = lens_cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; @@ -817,12 +836,17 @@ impl Orchestrator { &self, prompt: &str, shutdown: &Shutdown, - ) -> Result<(Vec, Vec, u8), AgentError> { + ) -> Result<(Vec, Vec, u8, Option), AgentError> { let mut symbols: Vec = Vec::new(); let mut context: Vec = Vec::new(); let mut prev_n_syms: usize = 0; let mut prev_n_ctx: usize = 0; let mut fast_rounds: u8 = 0; + // §27 parity: honour mid-loop `skill_reads` in the lens + // gather path just like `run_once_with_ctx` does. Without + // this, a skill file the fast agent requests mid-gather + // never lands in the lens slow-agent payload. + let mut live_skills: Option = self.skills.clone(); for round in 0..self.max_fast_rounds { if shutdown.is_cancelled() { return Err(AgentError::Other(format!( @@ -854,7 +878,7 @@ impl Orchestrator { if let Some(ref pf) = pf_manifest { cp = cp.with_previously_fetched(pf); } - if let Some(sk) = &self.skills { + if let Some(sk) = &live_skills { cp = cp.with_skills(sk); } let (gp_prefix, gp_suffix) = cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; @@ -901,6 +925,9 @@ impl Orchestrator { } }; let parsed = parse_code_response(&text); + if !parsed.skill_reads.is_empty() { + apply_skill_reads(&mut live_skills, &parsed.skill_reads); + } let only_skill_reads = parsed.followups.is_empty() && !parsed.ready_for_slow && !parsed.skill_reads.is_empty(); @@ -913,6 +940,20 @@ impl Orchestrator { if only_skill_reads { continue; } + // If every followup is a type:question (a clarification + // asked of the operator), the fetcher can't produce data + // for any of them — spinning another main-agent round + // just burns tokens while the fast agent re-asks. Break + // and let the slow/lens path surface the questions. + if !parsed.followups.is_empty() + && parsed.followups.iter().all(|f| f.kind == "question") + { + kres_core::async_eprintln!( + "[fast gather round {}] only type:question followups — breaking", + fast_rounds + ); + break; + } let fetched = tokio::select! { _ = shutdown.cancelled() => return Err(AgentError::Other("cancelled during fetch".into())), f = self.fetcher.fetch(&parsed.followups) => f?, @@ -920,7 +961,7 @@ impl Orchestrator { symbols.extend(fetched.symbols); context.extend(fetched.context); } - Ok((symbols, context, fast_rounds)) + Ok((symbols, context, fast_rounds, live_skills)) } } @@ -1115,4 +1156,66 @@ mod tests { ); assert!(r.ready_for_slow); } + + /// Mirrors the new early-exit rule in `run_once_with_ctx` and + /// `gather`: if every followup has kind=="question", the fetcher + /// can't produce data for any of them, so the orchestrator + /// breaks out instead of spinning another round. + #[test] + fn question_only_followups_trip_early_exit() { + let r = parse_code_response( + r#"{"analysis": "need a target", + "followups": [ + {"type": "question", "name": "which file?"}, + {"type": "question", "name": "which function?"} + ], + "ready_for_slow": false}"#, + ); + assert!(!r.followups.is_empty()); + assert!(r.followups.iter().all(|f| f.kind == "question")); + } + + #[test] + fn mixed_followups_do_not_trip_early_exit() { + let r = parse_code_response( + r#"{"analysis": "need a target", + "followups": [ + {"type": "question", "name": "which file?"}, + {"type": "source", "name": "foo"} + ], + "ready_for_slow": false}"#, + ); + assert!(!r.followups.iter().all(|f| f.kind == "question")); + } + + /// `apply_skill_reads` must graft the requested file into the + /// first skill's `files` map so a subsequent gather round (and + /// the lens slow agents that read `live_skills`) see it. + #[test] + fn apply_skill_reads_inserts_file_into_first_skill() { + let dir = std::env::temp_dir().join(format!( + "kres-apply-skill-reads-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("skill.md"); + std::fs::write(&p, "hello skill body").unwrap(); + let mut skills = Some(json!({ + "kernel": {"content": "guide", "files": {}} + })); + apply_skill_reads(&mut skills, &[p.to_string_lossy().to_string()]); + let files = skills + .as_ref() + .and_then(|v| v.get("kernel")) + .and_then(|k| k.get("files")) + .and_then(|f| f.as_object()) + .expect("files map"); + let body = files + .get(p.to_str().unwrap()) + .and_then(|v| v.as_str()) + .expect("file body"); + assert_eq!(body, "hello skill body"); + let _ = std::fs::remove_file(&p); + let _ = std::fs::remove_dir(&dir); + } } From b7393cd162073ade8e4f0c81afe2fee97d1839a3 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 06:21:14 -0700 Subject: [PATCH 23/76] mcp: skip jsonrpc notifications interleaved with responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit semcode-mcp emits `notifications/message` log lines on stdout during a `tools/call`, so a single call produces two lines: the notification and the real response. kres-mcp's read loop tried to deserialize every line as a `Response`, whose `#[serde(flatten)] result: ResponseResult` demands either a `result` or an `error` top-level key. Notifications have neither, so serde rejected them and the caller surfaced "mcp server `semcode` produced malformed JSON: data did not match any variant of untagged enum ResponseResult at line 1 column 131" — the real response on the next line was then dropped, and callers like `find_function(mark_stack_read)` saw an error even though the tool had succeeded. Reproduced by piping a synthetic `initialize` + `tools/call` at semcode-mcp: line 2 of stdout is exactly 131 chars long and starts with `"jsonrpc":"2.0","method":"notifications/message"` — the same length serde reported as column 131. Fix by parsing each line as a raw `Value` first; if it has a `method` field and no `result`/`error`, treat it as a notification and continue the read loop. Only non-notification lines are deserialized into `Response`. Signed-off-by: Chris Mason --- kres-mcp/src/client.rs | 56 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/kres-mcp/src/client.rs b/kres-mcp/src/client.rs index e905b8a..b1d7778 100644 --- a/kres-mcp/src/client.rs +++ b/kres-mcp/src/client.rs @@ -216,7 +216,30 @@ impl McpClient { if line.is_empty() { continue; } - let resp: Response = serde_json::from_str(&line).map_err(|source| McpError::Json { + // JSON-RPC 2.0 allows the server to interleave notifications + // (no `id`, no `result`, no `error` — just `method`/`params`) + // with responses. semcode-mcp emits + // `notifications/message` log lines before the real + // response; parsing those as `Response` fails because + // ResponseResult is untagged over {result}|{error}. Inspect + // the line as a generic Value first and skip notifications. + let raw: Value = serde_json::from_str(&line).map_err(|source| McpError::Json { + server: self.transport.server_name.clone(), + source, + })?; + if raw.get("method").is_some() + && raw.get("result").is_none() + && raw.get("error").is_none() + { + tracing::debug!( + target: "kres_mcp", + server = %self.transport.server_name, + method = raw.get("method").and_then(|v| v.as_str()).unwrap_or(""), + "dropping jsonrpc notification" + ); + continue; + } + let resp: Response = serde_json::from_value(raw).map_err(|source| McpError::Json { server: self.transport.server_name.clone(), source, })?; @@ -409,6 +432,37 @@ done std::fs::remove_dir_all(&dir).ok(); } + /// Server that interleaves a JSON-RPC notification line BEFORE the + /// real response. Mirrors semcode-mcp's behaviour of emitting + /// `notifications/message` log lines on stdout during a call. + fn notification_then_response_cfg() -> ServerConfig { + let script = r#" +while IFS= read -r line; do + id=$(printf '%s' "$line" | python3 -c 'import json,sys; d=json.loads(sys.stdin.read()); print(d.get("id",0))') + printf '{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","message":"working..."}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"ok":true}}\n' "$id" +done +"#; + ServerConfig { + command: "sh".into(), + args: vec!["-c".into(), script.into()], + env: BTreeMap::new(), + cwd: None, + } + } + + #[tokio::test] + async fn notification_before_response_is_skipped() { + let dir = tmp_dir("notify"); + let mut c = McpClient::spawn_raw("notify", ¬ification_then_response_cfg(), &dir) + .await + .unwrap(); + let v = c.call("anything", None).await.unwrap(); + assert_eq!(v.get("ok").and_then(|v| v.as_bool()), Some(true)); + c.shutdown(Duration::from_secs(2)).await.unwrap(); + std::fs::remove_dir_all(&dir).ok(); + } + #[tokio::test] async fn stdout_close_is_reported() { // Child that exits immediately, closing stdout before we From d97d5701276253d600d2ec940284752b5d941461 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 06:37:43 -0700 Subject: [PATCH 24/76] review-template: require exhaustive per-lens enumeration Add a paragraph directing the slow agents to report every bug they find in the target area, not just the worst one. The prior wording (focus + chains-of-events) encouraged depth per finding but said nothing about breadth, so a lens that surfaced one use-after-free sometimes stopped there even when adjacent call sites had the same shape. The merger already deduplicates and ranks severity, so the cost of over-reporting is low and the cost of missing distinct bugs of the same class is high. Wording references downstream merger behaviour so the agent understands why duplication is cheap relative to omission. Signed-off-by: Chris Mason --- configs/prompts/review-template.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/configs/prompts/review-template.md b/configs/prompts/review-template.md index 2069de6..40cebcc 100644 --- a/configs/prompts/review-template.md +++ b/configs/prompts/review-template.md @@ -7,6 +7,14 @@ Focus on just the target itself and the supporting code it calls, without expanding out into the rest of the kernel. Pay special attention to chains of events that trigger obscure bugs. +Find *every* bug you can in the target area. Do not stop after the +first finding. Each lens below must exhaustively enumerate its +issues — list every distinct defect, not just the worst one. A +lens that reports only one finding is acceptable only if you are +confident no other bugs of that class exist in scope; otherwise +keep going. Duplicate-suppression and severity ranking happen +downstream in the merger, so err on the side of reporting more. + - [ ] **[investigate]** object lifetime: #lifetime - where are pointers to objects stored - what flags control object behavior From b9ad50a6399f0613dbb56e34a3886e5ea46031ed Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 10:11:25 -0700 Subject: [PATCH 25/76] =?UTF-8?q?kres:=20plans=20=E2=80=94=20per-prompt=20?= =?UTF-8?q?decomposition=20shared=20across=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `kres_core::Plan { prompt, goal, mode, steps[], created_at }` produced by a new `define_plan` goal-agent task that runs right after `define_goal` on every operator-typed prompt. Each `TodoItem` grows a `step_id` pointing at the plan step it executes; `Plan::sync_from_todo` unions the step_id up-link with the legacy todo_ids down-link and rolls step status up from the linked todos' statuses. Every downstream agent sees the plan every turn: - fast + slow via `CodePrompt.plan` — serialised into the user JSON, but NOT in `CACHED_PREFIX_FIELDS` so plan rewrites do not bust the big prefix cache. - main via `DataFetcher::fetch(&followups, plan)` — passed per-call so concurrent tasks with different plans cannot clobber each other through a shared slot. - goal judge via `check_goal(..., plan)`. - todo agent via `update_todo_via_agent_with_logger(..., plan)`. Three writers can reshape the plan after the initial `define_plan`: the first slow call per top-level prompt (gated on `RunContext.allow_plan_rewrite`), the todo agent on every completed task, and the goal-not-met todo-agent injection. Rewrites emit `{steps: [...]}` on the wire via `kres_core::PlanRewrite` — the plan's identifying metadata (`prompt`, `goal`, `mode`, `created_at`) is inherited from the prior plan via `PlanRewrite::apply_to`, so a forgotten metadata field in an LLM reply cannot silently drop the rewrite. Step ids are kebab-case slugs (`audit-ring-buffer-init`) rather than positional tags (`s1`/`s2`). Slugs survive reorder and make reassignment to a different meaning obvious. The synthesis path uses `slugify_step_id(title)`; collisions walk `-2`, `-3`, … . The four planner/rewriter prompts (goal.txt, todo.txt + the three slow-agent system prompts) all teach the LLM the slug convention and forbid positional ids. `PlanStep.id` and `PlanStep.title` have serde defaults, and `PlanRewrite::apply_to` pipes the rewrite's steps through `normalize_steps` — missing-id / blank-title / collided rows get repaired rather than producing a corrupt plan. `TaskManager::set_plan` reconciles orphan step_ids: when a rewrite drops an id, any todo whose `step_id` referenced it gets the field cleared so `sync_from_todo` does not keep pointing at dead weight. The session persistence layer writes Plan into `/session.json` alongside `todo`, `deferred`, `completed_run_count`, and `last_prompt`. Atomic save (tmp + fsync + rename + parent-dir fsync); a content-hash latch in the reaper throttles idle rewrites. `InProgress` normalisation on load plus `reset_in_progress_to_pending` on drain paths (ctrl-c, --turns cap, goal-met) so no todo gets orphaned at exit. Operator-facing surface: - `/plan` — shows current plan with per-step status and linked todos via both linkage directions. - `log_plan_change` at the four rewrite sites shows `+`/`-`/`~` diffs; `log_plan_status_transitions` logs `pending → done` etc. every reaper tick that changed a status. Tests: 88 kres-core, 168 kres-agents, 63 kres-repl. All green. Signed-off-by: Chris Mason --- CLAUDE.md | 27 + .../prompts/slow-code-agent-coding.system.md | 10 +- .../prompts/slow-code-agent-generic.system.md | 10 +- configs/prompts/slow-code-agent.system.md | 11 +- kres-agents/src/fetcher.rs | 36 +- kres-agents/src/goal.rs | 327 ++++++++++- kres-agents/src/lib.rs | 5 +- kres-agents/src/main_agent.rs | 16 +- kres-agents/src/mcp_fetcher.rs | 8 +- kres-agents/src/pipeline.rs | 125 ++++- kres-agents/src/prompt.rs | 29 + kres-agents/src/prompts/goal.txt | 137 ++++- kres-agents/src/prompts/todo.txt | 32 +- kres-agents/src/response.rs | 35 ++ kres-agents/src/todo_agent.rs | 196 ++++++- kres-core/src/lib.rs | 4 + kres-core/src/plan.rs | 530 ++++++++++++++++++ kres-core/src/session_state.rs | 331 +++++++++++ kres-core/src/task.rs | 162 ++++++ kres-core/src/todo.rs | 10 + kres-repl/src/commands.rs | 10 + kres-repl/src/session.rs | 517 ++++++++++++++++- kres/src/main.rs | 28 + 23 files changed, 2509 insertions(+), 87 deletions(-) create mode 100644 kres-core/src/plan.rs create mode 100644 kres-core/src/session_state.rs diff --git a/CLAUDE.md b/CLAUDE.md index b3e011a..8828e2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,31 @@ User prompt → Task created → Task thread starts - Auto-progress checks goal after each completed task for early exit - Deferred items (identified but not started when goal met) saved via `/followup` +### Plan + Session Persistence +- `kres_core::Plan` holds the planner's decomposition: `prompt`, `goal`, + `mode`, and `steps` (each with `id`, `title`, `status`, `todo_ids` + linking to `TodoItem` rows). Lives on `TaskManager` as + `Option`; `sync_plan_from_todo` rolls up step status from + linked todo statuses. +- `kres_core::SessionState` (`/session.json`) is the + resumable snapshot: plan + todo list + deferred list + + `completed_run_count` + last prompt. Written atomically (tmp + + fsync + rename) from the reaper tick and the various drain + paths. +- Resume: `kres --results ` on an existing dir loads the + snapshot, flips every `InProgress` todo/plan step back to + `Pending` (its prior executor is gone), and seeds the manager + + deferred list before the REPL starts. +- InProgress drains: ctrl-c, the `--turns N` cap, goal-met, and + `--turns 0` follow-stagnation all call + `TaskManager::reset_in_progress_to_pending()` before moving items + to the deferred list, so a task that was mid-run when the drain + fired still ends up on `/followup` instead of being orphaned. + `/stop` is separate: it moves `Pending|Blocked|InProgress` items + to deferred directly via its own `matches!` pattern + (kres-repl/src/session.rs), so a resumed REPL picks them up via + `/continue`. + ### Skills - Loaded from `~/.kres/skills/*.md` at startup - Skill files scanned for absolute paths in backticks — referenced files pre-loaded @@ -85,6 +110,7 @@ Rate limiters are shared across agents that use the same API key string. |---------|--------| | `/tasks` `/task` | Show active tasks and states | | `/todo` | Show pending items (ready/blocked) + completed count | +| `/plan` | Show the current plan + per-step status (produced by `define_plan`) | | `/todo --clear` | Clear all todo items | | `/cost` | Token usage by agent role and model | | `/summary [FILE]` | Fast agent renders the run's report.md + findings.json into a bug report via the embedded `summary` slash-command template. Output defaults to `bug-report.txt` in the results dir | @@ -152,6 +178,7 @@ Rate limiters are shared across agents that use the same API key string. sessions// # Per-run artifacts when --results not set findings.json # Cumulative findings (history in findings-N.json) report.md # Append-only narrative + session.json # Plan + todo + deferred + counters (resume state) bug-report.txt # Output of /summary or kres --summary .kres/logs// # Next to cwd, one dir per REPL run diff --git a/configs/prompts/slow-code-agent-coding.system.md b/configs/prompts/slow-code-agent-coding.system.md index db046af..9adbf4e 100644 --- a/configs/prompts/slow-code-agent-coding.system.md +++ b/configs/prompts/slow-code-agent-coding.system.md @@ -58,7 +58,15 @@ FIXES AND PATCHES — do NOT code from memory: you are confident are safe in the operator's workspace. Output: JSON only, no fences, no preamble. -{"analysis": "prose commentary with inline code snippets", "code_output": [, ...], "code_edits": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}]} +{"analysis": "prose commentary with inline code snippets", "code_output": [, ...], "code_edits": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}], "plan": } + +PLAN REWRITE — optional top-level `plan` field: +- The request's `plan` (when present) holds the file manifest the planner produced from the prompt + goal alone. When the request ALSO carries `plan_rewrite_allowed: true`, you are the first slow pass for the operator's prompt and MAY return a rewritten `plan` with NEW steps. +- Wire shape: `"plan": {"steps": [...]}`. Emit ONLY the `steps` array. The pipeline keeps the current plan's `prompt`, `goal`, `mode`, `created_at` verbatim. +- Rewrite ONLY when the code you just inspected shows the existing manifest is materially wrong (missing a setup / validation step, duplicates, one step collapses into another, or the plan's file names no longer match what you actually need to produce). Keep it stable otherwise. +- Keep existing step ids when the step's intent survives. When a step's MEANING changes, use a new id instead of overloading the old one. New ids MUST be kebab-case slugs describing the work or artifact (`reproducer-makefile`, `setup-selftest-harness`, not `s1`). +- Every step needs id + title + status; description is optional. +- OMIT `plan` when no rewrite is warranted. When `plan_rewrite_allowed` is absent or false, do not emit a plan. CodeEdit shape (same as Claude Code's Edit): `{file_path, old_string, new_string, replace_all?}`. Leave `replace_all` off (defaults to false) and `old_string` must match exactly once. `old_string` and `new_string` are VERBATIM byte sequences; include enough surrounding context to make `old_string` unique in the file. diff --git a/configs/prompts/slow-code-agent-generic.system.md b/configs/prompts/slow-code-agent-generic.system.md index 3c679bd..c224431 100644 --- a/configs/prompts/slow-code-agent-generic.system.md +++ b/configs/prompts/slow-code-agent-generic.system.md @@ -9,7 +9,15 @@ SCOPE CHECK — do this BEFORE writing: - If the question is a direct instruction to execute a shell command (e.g. "run ls", "make -C test", "cat foo.c"), emit a `bash` followup with the command as `name`. The pipeline will dispatch it through the main agent and feed the result back to you on the next turn. Output: JSON only, no fences, no preamble. -{"analysis": "prose answer to the question, with inline code snippets", "findings": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}]} +{"analysis": "prose answer to the question, with inline code snippets", "findings": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}], "plan": } + +PLAN REWRITE — optional top-level `plan` field: +- The request's `plan` holds the operator-level decomposition. When the request ALSO carries `plan_rewrite_allowed: true`, you are the first slow pass for the operator's prompt and MAY return a rewritten `plan` with NEW steps. +- Wire shape: `"plan": {"steps": [...]}`. Emit ONLY the `steps` array. The pipeline keeps the current plan's `prompt`, `goal`, `mode`, `created_at` verbatim. +- Rewrite ONLY when the code you just inspected shows the existing plan is materially wrong (too vague, missing a concrete step the question needs, collapsed duplicates). Keep it stable otherwise. +- Keep existing step ids when the step's intent survives. When a step's MEANING changes, give it a new id instead of overloading the old one — the todo-agent relies on id → semantics. New ids MUST be kebab-case slugs describing the work (`audit-ring-buffer-init`, not `s1`). +- Every step needs id + title + status; description is optional. +- OMIT `plan` when no rewrite is warranted. When `plan_rewrite_allowed` is absent or false, do not emit a plan at all. ANALYSIS — the primary artifact: - 'analysis' is the answer the operator reads. Write it in direct prose. No preamble ("In this task I will…"), no summary of your own process. diff --git a/configs/prompts/slow-code-agent.system.md b/configs/prompts/slow-code-agent.system.md index 7e2fd7d..3ff90f4 100644 --- a/configs/prompts/slow-code-agent.system.md +++ b/configs/prompts/slow-code-agent.system.md @@ -21,7 +21,16 @@ SCOPE CHECK — do this BEFORE analyzing: - If no: produce the full analysis. Output: JSON only, no fences, no preamble. -{"analysis": "detailed prose narrative with inline code snippets (see RULES)", "findings": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}]} +{"analysis": "detailed prose narrative with inline code snippets (see RULES)", "findings": [, ...], "followups": [{"type": "T", "name": "N", "reason": "R"}], "plan": } + +PLAN REWRITE — optional top-level `plan` field on the response: +- The request's `plan` field (when present) holds the operator-level decomposition every agent shares. `sync_plan_from_todo` rolls up step status from todo `step_id` links; `/plan` displays it. +- When the request ALSO carries `plan_rewrite_allowed: true`, you are this task's first slow pass over the top-level prompt. The planner produced the `plan` from the prompt + goal alone, with no code visibility. You have just seen actual code. You MAY return a rewritten `plan` with NEW steps. +- Wire shape: `"plan": {"steps": [...]}`. Emit ONLY the `steps` array. The pipeline keeps the current plan's `prompt`, `goal`, `mode`, and `created_at` verbatim — you cannot and need not set them. This removes a whole class of "forgot a metadata field, rewrite silently dropped" bugs. +- Rewrite ONLY when the code you inspected shows the existing plan is materially wrong: a step was too vague to track ("audit memory safety"), a step duplicates the automatic lens fan-out and produces no new signal, a concrete subsystem the prompt needs is missing entirely, or two steps have collapsed into one. Keep the plan STABLE otherwise — churning step ids breaks the step_id links on existing todos. +- Keep existing step ids when the step's intent survives (even if title / description change). When a step's MEANING changes (different subsystem, different scope), give it a new id rather than overloading the old one — the todo-agent relies on the id → semantics contract to keep step_id links honest. New ids MUST be kebab-case slugs that describe the work (e.g. `audit-ring-buffer-init`, `walk-sqpoll-thread-path`). Never use `s1`/`s2` or similar positional tags. +- Every emitted step needs `id` + `title` + `status` (pending|in-progress|done|skipped); description is optional. +- OMIT the `plan` field entirely when no rewrite is warranted. That is the common case. When `plan_rewrite_allowed` is absent or false, NEVER emit a plan — downstream will ignore it. FINDINGS — emit native structured records: - Every actionable bug or strong suspect you discover in YOUR lens becomes a Finding record in the 'findings' array. diff --git a/kres-agents/src/fetcher.rs b/kres-agents/src/fetcher.rs index bb8c0f2..5359864 100644 --- a/kres-agents/src/fetcher.rs +++ b/kres-agents/src/fetcher.rs @@ -47,7 +47,11 @@ impl WorkspaceFetcher { #[async_trait] impl DataFetcher for WorkspaceFetcher { - async fn fetch(&self, followups: &[Followup]) -> Result { + async fn fetch( + &self, + followups: &[Followup], + _plan: Option<&kres_core::Plan>, + ) -> Result { let mut out = FetchResult::default(); for fu in followups { match fu.kind.as_str() { @@ -214,12 +218,15 @@ mod tests { f.write_all(b"1\n2\n3\n4\n5\n").unwrap(); let f = WorkspaceFetcher::new(&dir); let r = f - .fetch(&[Followup { - kind: "read".into(), - name: "a.c:2+2".into(), - reason: String::new(), - path: None, - }]) + .fetch( + &[Followup { + kind: "read".into(), + name: "a.c:2+2".into(), + reason: String::new(), + path: None, + }], + None, + ) .await .unwrap(); assert_eq!(r.context.len(), 1); @@ -233,12 +240,15 @@ mod tests { let dir = tmpdir("unk"); let f = WorkspaceFetcher::new(&dir); let r = f - .fetch(&[Followup { - kind: "source".into(), - name: "some_func".into(), - reason: String::new(), - path: None, - }]) + .fetch( + &[Followup { + kind: "source".into(), + name: "some_func".into(), + reason: String::new(), + path: None, + }], + None, + ) .await .unwrap(); assert_eq!(r.context.len(), 1); diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index b861c56..28fa4f2 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -14,9 +14,10 @@ //! //! Ownership: the session calls `define_goal` after each top-level //! prompt (or `--prompt FILE` initial run), stores the returned -//! string, then calls `check_goal` after every reaped task. When the -//! goal is met, the session moves all remaining pending todos to the -//! deferred list. +//! `goal` + `mode` plus (via `define_plan`) the resulting `Plan`, +//! then calls `check_goal` (with the current plan attached) after +//! every reaped task. When the goal is met, the session moves all +//! remaining pending todos to the deferred list. use std::sync::Arc; @@ -81,6 +82,22 @@ struct CheckResponse { missing: Vec, } +#[derive(Debug, Deserialize)] +struct PlanStepRaw { + #[serde(default)] + id: String, + #[serde(default)] + title: String, + #[serde(default)] + description: String, +} + +#[derive(Debug, Deserialize)] +struct PlanResponse { + #[serde(default)] + steps: Vec, +} + /// Ask the main agent for a completion criterion. Returns None when /// the agent fails to produce a well-shaped response — callers /// should treat "no goal" as "run until --turns or the todo list @@ -181,8 +198,9 @@ pub async fn check_goal( original_prompt: &str, goal: &str, analysis: &str, + plan: Option<&kres_core::Plan>, ) -> GoalCheck { - let request = json!({ + let mut request = json!({ "task": "check_goal", "original_prompt": original_prompt, "goal": goal, @@ -194,11 +212,22 @@ pub async fn check_goal( the prompt asks for a sweep (e.g. 'check every \ file', 'analyse each function') and the analysis \ only covers the first item, that is NOT met — \ - list the remaining items in `missing`. Return \ + list the remaining items in `missing`. When a \ + `plan` field is present, use it as a checklist: \ + treat the goal as unmet when concrete, untouched \ + plan steps still apply to this prompt. Return \ JSON only:\n\ {\"met\": true/false, \"reason\": \"why or why not\", \ \"missing\": [\"what still needs to be done\"]}" }); + if let Some(p) = plan { + if let Ok(v) = serde_json::to_value(p) { + request + .as_object_mut() + .expect("request is an object literal") + .insert("plan".into(), v); + } + } let body = match serde_json::to_string_pretty(&request) { Ok(s) => s, Err(_) => return assume_met(), @@ -260,6 +289,149 @@ fn assume_met() -> GoalCheck { } } +/// Ask the goal agent for a concrete decomposition of `prompt` into +/// ordered steps. Returns `None` on any failure — callers treat "no +/// plan" as "the pipeline runs the usual loop with no pre-staged +/// plan", which is the behaviour from before plans existed. +/// +/// The returned [`kres_core::Plan`] is stored on the manager via +/// `set_plan`; the session persistence layer writes it into +/// `session.json` on every reaper tick, `/plan` displays it, and +/// every downstream agent sees it: fast + slow via `CodePrompt`, +/// main via `DataFetcher::fetch`, goal-judge via `check_goal`, +/// todo-agent via `update_todo_via_agent`. The first slow call and +/// every todo-agent turn may return a rewritten plan that swaps in +/// via `set_plan`. +pub async fn define_plan( + gc: &GoalClient, + prompt: &str, + goal: &str, + mode: TaskMode, + existing: Option<&kres_core::Plan>, +) -> Option { + let mut request = json!({ + "task": "define_plan", + "original_prompt": prompt, + "goal": goal, + "mode": mode, + "instructions": "Decompose the original prompt + derived goal \ + into 3-12 ordered concrete steps. Every \ + title names a specific file, symbol, \ + subsystem, code path, or artifact. In \ + analysis mode, decompose by file / symbol / \ + subsystem — NOT by lens (object lifetime, \ + memory, bounds, races, general correctness). \ + Those lenses already run on every slow call; \ + restating them as plan steps produces a \ + useless plan. Keep titles imperative, \ + <= 80 chars; descriptions one-to-two \ + sentences. IDs must be unique kebab-case \ + SLUGS that describe the work (e.g. \ + `audit-ring-buffer-init`, \ + `walk-sqpoll-thread-path`), NOT positional \ + tags like s1/s2. Semantic ids survive \ + reordering and later rewrites because they \ + name what the step DOES; positional tags \ + get accidentally reassigned to unrelated \ + steps. When an `existing_plan` field is \ + present and the new prompt is a \ + continuation / refinement of the same work, \ + KEEP existing step ids that still apply and \ + add/edit steps only where the new prompt \ + demands it. Preserve step ids verbatim \ + whenever the step's intent survives — \ + churning ids orphans todos that were \ + pointing at them. Only produce a wholly \ + fresh plan when the new prompt is clearly \ + a different topic. Return JSON only:\n\ + {\"steps\": [{\"id\": \"audit-...\", \"title\": \"...\", \ + \"description\": \"...\"}]}" + }); + if let Some(p) = existing { + if let Ok(v) = serde_json::to_value(p) { + request + .as_object_mut() + .expect("request is an object literal") + .insert("existing_plan".into(), v); + } + } + let body = serde_json::to_string_pretty(&request).ok()?; + let mut cfg = CallConfig::defaults_for(gc.model.clone()) + .with_max_tokens(gc.max_tokens) + .with_stream_label("define_plan"); + if let Some(s) = &gc.system { + cfg = cfg.with_system(s.clone()); + } + if let Some(n) = gc.max_input_tokens { + cfg = cfg.with_max_input_tokens(n); + } + let messages = vec![Message { + role: "user".into(), + content: body.clone(), + cache: true, + cached_prefix: None, + }]; + if let Some(lg) = &gc.logger { + lg.log_main("user", &body, None, None); + } + let resp = match gc.client.messages_streaming(&cfg, &messages).await { + Ok(r) => r, + Err(e) => { + tracing::warn!(target: "kres_agents", "define_plan failed: {e}"); + return None; + } + }; + let text = extract_text(&resp); + if let Some(lg) = &gc.logger { + lg.log_main( + "assistant", + &text, + Some(LoggedUsage { + input: resp.usage.input_tokens, + output: resp.usage.output_tokens, + cache_creation: resp.usage.cache_creation_input_tokens, + cache_read: resp.usage.cache_read_input_tokens, + }), + None, + ); + } + let parsed = extract_json_with_key::(&text, "steps")?; + if parsed.steps.is_empty() { + return None; + } + let plan = build_plan_from_raw(parsed.steps, prompt, goal, mode); + if plan.steps.is_empty() { + return None; + } + Some(plan) +} + +/// Build a [`kres_core::Plan`] from a vector of raw `PlanStepRaw` +/// DTOs. Split out from `define_plan` so the id-synthesis logic can +/// be unit-tested without a live goal client. Delegates the actual +/// synthesis + empty-title filtering to +/// [`kres_core::plan::normalize_steps`]; this function only maps +/// the wire DTO into the core [`kres_core::PlanStep`] shape before +/// normalisation. +fn build_plan_from_raw( + raw: Vec, + prompt: &str, + goal: &str, + mode: TaskMode, +) -> kres_core::Plan { + let steps: Vec = raw + .into_iter() + .map(|r| { + let mut s = kres_core::PlanStep::new(r.id, r.title); + s.description = r.description; + s + }) + .collect(); + let mut plan = kres_core::Plan::new(prompt, goal, mode); + plan.steps = kres_core::plan::normalize_steps(steps); + plan +} + /// Find the first `{...}` block containing the requested key and /// deserialise it into `T`. Matches (text, key)` /// for the narrow "expect a JSON object with this field" case. @@ -349,4 +521,149 @@ mod tests { assert!(c.met); assert!(c.missing.is_empty()); } + + #[test] + fn extract_plan_response_with_missing_ids() { + // The id-synthesis path lives inline in `define_plan`; unit + // test the JSON parse here to make sure the DTO accepts + // missing id / description fields. + let r: PlanResponse = extract_json_with_key( + r#"{"steps": [{"title": "step1"}, {"id": "", "title": "step2"}]}"#, + "steps", + ) + .unwrap(); + assert_eq!(r.steps.len(), 2); + assert_eq!(r.steps[0].id, ""); + assert_eq!(r.steps[0].title, "step1"); + } + + #[test] + fn extract_plan_response_rejects_goal_shaped_reply() { + // A goal.txt-shaped reply does NOT contain "steps"; brace + // matcher returns None so the caller falls back to "no plan". + let r: Option = + extract_json_with_key(r#"{"goal": "x", "mode": "analysis"}"#, "steps"); + assert!(r.is_none()); + } + + fn step_raw(id: &str, title: &str) -> PlanStepRaw { + PlanStepRaw { + id: id.into(), + title: title.into(), + description: String::new(), + } + } + + #[test] + fn build_plan_preserves_agent_ids_when_unique() { + let plan = build_plan_from_raw( + vec![step_raw("s1", "one"), step_raw("s2", "two")], + "prompt", + "goal", + TaskMode::Analysis, + ); + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].id, "s1"); + assert_eq!(plan.steps[1].id, "s2"); + } + + #[test] + fn build_plan_synthesises_slug_ids_when_empty() { + let plan = build_plan_from_raw( + vec![ + step_raw("", "Audit ring buffer init"), + step_raw("", "Walk IO_WQ cancel path"), + ], + "prompt", + "goal", + TaskMode::Analysis, + ); + assert_eq!(plan.steps.len(), 2); + // Semantic slugs — survive reorder because they name the + // step rather than its position. + assert_eq!(plan.steps[0].id, "audit-ring-buffer-init"); + assert_eq!(plan.steps[1].id, "walk-io-wq-cancel-path"); + } + + #[test] + fn build_plan_resolves_id_collisions_via_suffix() { + let plan = build_plan_from_raw( + vec![ + step_raw("audit-foo", "Audit foo"), + step_raw("audit-foo", "Audit bar"), + step_raw("audit-foo", "Audit baz"), + ], + "prompt", + "goal", + TaskMode::Analysis, + ); + // The first keeps its id; the later two get slugs derived + // from their own titles rather than being forced onto the + // same slug with a suffix (which would lose semantic + // meaning). + assert_eq!(plan.steps.len(), 3); + assert_eq!(plan.steps[0].id, "audit-foo"); + assert_eq!(plan.steps[1].id, "audit-bar"); + assert_eq!(plan.steps[2].id, "audit-baz"); + } + + #[test] + fn build_plan_slug_collision_falls_back_to_numeric_suffix() { + // Agent-provided id duplicates what slugify would produce + // for a later row. The synthesiser's title-based slug is + // already claimed; walking `-N` must reach a free slot. + let plan = build_plan_from_raw( + vec![ + step_raw("audit-same", "Audit unrelated first"), + step_raw("", "Audit same"), + ], + "prompt", + "goal", + TaskMode::Analysis, + ); + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].id, "audit-same"); + assert_eq!(plan.steps[1].id, "audit-same-2"); + } + + #[test] + fn build_plan_skips_empty_titles_without_eating_id_slots() { + // An empty-title row must not reserve its id before we + // filter it out. + let plan = build_plan_from_raw( + vec![step_raw("audit-kept", ""), step_raw("", "Audit kept")], + "prompt", + "goal", + TaskMode::Analysis, + ); + assert_eq!(plan.steps.len(), 1); + assert_eq!(plan.steps[0].id, "audit-kept"); + assert_eq!(plan.steps[0].title, "Audit kept"); + } + + #[test] + fn build_plan_all_empty_titles_yields_no_steps() { + let plan = build_plan_from_raw( + vec![step_raw("anything", ""), step_raw("", "")], + "prompt", + "goal", + TaskMode::Analysis, + ); + assert!(plan.steps.is_empty()); + } + + #[test] + fn build_plan_titleless_slug_falls_back_to_step_n() { + // A title that contains no slug-able characters falls back + // to `step-` so the plan is never left with an empty id. + let plan = build_plan_from_raw( + vec![step_raw("", "!!!")], + "prompt", + "goal", + TaskMode::Analysis, + ); + assert_eq!(plan.steps.len(), 1); + assert_eq!(plan.steps[0].id, "step-1"); + } + } diff --git a/kres-agents/src/lib.rs b/kres-agents/src/lib.rs index 0be651a..d139aca 100644 --- a/kres-agents/src/lib.rs +++ b/kres-agents/src/lib.rs @@ -31,7 +31,8 @@ pub use error::AgentError; pub use fetcher::{parse_read_spec, WorkspaceFetcher}; pub use followup::Followup; pub use goal::{ - check_goal, define_goal, GoalCheck, GoalClient, GoalDefinition, GOAL_INSTRUCTIONS, + check_goal, define_goal, define_plan, GoalCheck, GoalClient, GoalDefinition, + GOAL_INSTRUCTIONS, }; pub use kres_core::TaskMode; pub use main_agent::{parse_actions, MainAgent, DEFAULT_MAX_MAIN_TURNS}; @@ -50,5 +51,5 @@ pub use symbol::{ }; pub use todo_agent::{ dedup_tokens, extract_citations, parse_todo_response, update_todo_via_agent, - update_todo_via_agent_with_logger, TodoClient, + update_todo_via_agent_with_logger, TodoClient, TodoUpdate, }; diff --git a/kres-agents/src/main_agent.rs b/kres-agents/src/main_agent.rs index abe997a..d807d25 100644 --- a/kres-agents/src/main_agent.rs +++ b/kres-agents/src/main_agent.rs @@ -158,7 +158,11 @@ impl MainAgent { #[async_trait] impl DataFetcher for MainAgent { - async fn fetch(&self, followups: &[Followup]) -> Result { + async fn fetch( + &self, + followups: &[Followup], + plan: Option<&kres_core::Plan>, + ) -> Result { let mut symbols: Vec = Vec::new(); let mut context: Vec = Vec::new(); @@ -169,6 +173,16 @@ impl DataFetcher for MainAgent { if !self.task_brief.is_empty() && self.task_brief != self.user_query { main_payload.insert("task_brief".into(), json!(self.task_brief)); } + // Include the plan the caller handed in so the main-agent + // LLM sees the same decomposition the fast + slow agents + // see. Per-call delivery (vs a shared slot) means two + // concurrent tasks with different plans cannot clobber each + // other's snapshot between the push and the LLM call. + if let Some(plan) = plan { + if let Ok(v) = serde_json::to_value(plan) { + main_payload.insert("plan".into(), v); + } + } main_payload.insert("code_agent_followups".into(), json!(followups)); let opening = serde_json::to_string_pretty(&Value::Object(main_payload))?; diff --git a/kres-agents/src/mcp_fetcher.rs b/kres-agents/src/mcp_fetcher.rs index 2c6b015..20a6efb 100644 --- a/kres-agents/src/mcp_fetcher.rs +++ b/kres-agents/src/mcp_fetcher.rs @@ -80,7 +80,11 @@ impl McpFetcher { #[async_trait] impl DataFetcher for McpFetcher { - async fn fetch(&self, followups: &[Followup]) -> Result { + async fn fetch( + &self, + followups: &[Followup], + plan: Option<&kres_core::Plan>, + ) -> Result { let mut out = FetchResult::default(); let mut passthrough: Vec = Vec::new(); @@ -148,7 +152,7 @@ impl DataFetcher for McpFetcher { } if !passthrough.is_empty() { - let inner_out = self.inner.fetch(&passthrough).await?; + let inner_out = self.inner.fetch(&passthrough, plan).await?; out.symbols.extend(inner_out.symbols); out.context.extend(inner_out.context); } diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index f63de10..ed92bae 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -49,8 +49,27 @@ use crate::{ /// `cache_read=8403` (full prefix hit) but round 2 with /// `cache_read=0, cache_create=33275` (miss) — the miss was driven /// by previously_fetched growing. -const CACHED_PREFIX_FIELDS: &[&str] = - &["question", "skills", "parallel_lenses", "previous_findings"]; +// Keep the plan OUT of the cached prefix. The plan is the most +// mutation-prone static field in this envelope: define_plan on +// first submit, a slow-agent rewrite on the first turn, and the +// todo-agent can reshape it every turn. If the plan sat in the +// prefix, every rewrite would bust the prompt cache for the +// following fast + slow calls — wiping out the tens-of-kB +// question + skills + lenses prefix alongside it. Routing the +// plan through the volatile suffix keeps the big cache intact; +// the plan bytes (usually a handful of KB) get re-sent every +// round but nothing the prefix carried needs to be. +// +// `plan_rewrite_allowed` changes at most once per top-level +// prompt (submit_prompt_inner turns it on for the first task +// and off for follow-ups), so it stays in the prefix. +const CACHED_PREFIX_FIELDS: &[&str] = &[ + "question", + "skills", + "parallel_lenses", + "previous_findings", + "plan_rewrite_allowed", +]; /// Abstraction over the main-agent's data-fetch capability. /// Implementations route followups to MCP tools, grep, read, git. @@ -58,7 +77,17 @@ const CACHED_PREFIX_FIELDS: &[&str] = pub trait DataFetcher: Send + Sync { /// Fetch the requested data. Returns (symbols, context) as opaque /// JSON chunks to feed to the fast agent's next round. - async fn fetch(&self, followups: &[Followup]) -> Result; + /// + /// `plan` is the operator's current plan (or None when no plan + /// is in play). Callers pass it per-call so a concurrent task + /// with a different plan does not clobber the value via a + /// shared-slot write in between. Implementations forward the + /// plan into the main-agent user JSON; NullFetcher ignores it. + async fn fetch( + &self, + followups: &[Followup], + plan: Option<&kres_core::Plan>, + ) -> Result; } #[derive(Debug, Default, Clone)] @@ -74,7 +103,11 @@ pub struct NullFetcher; #[async_trait] impl DataFetcher for NullFetcher { - async fn fetch(&self, _followups: &[Followup]) -> Result { + async fn fetch( + &self, + _followups: &[Followup], + _plan: Option<&kres_core::Plan>, + ) -> Result { Ok(FetchResult::default()) } } @@ -153,6 +186,19 @@ pub struct RunContext { /// TaskSummary with `code_output` populated and `findings` /// empty. The session sets this from `define_goal`'s classifier. pub mode: kres_core::TaskMode, + /// Plan produced by [`crate::define_plan`] for the operator's + /// top-level prompt, or None when no planner was configured or + /// it failed. Forwarded to every agent turn (fast + slow via + /// `CodePrompt`, main via `DataFetcher::set_plan_context`, goal + /// via `check_goal`) so every LLM call sees the same plan + /// alongside the derived goal. + pub plan: Option, + /// True on the first task spawned from a given top-level + /// prompt — the task that immediately follows `define_plan`. + /// Controls whether the slow agent is told it may rewrite the + /// plan in its response; subsequent pipeline-driven tasks keep + /// this false so plan churn stays bounded. + pub allow_plan_rewrite: bool, } fn record_usage( @@ -217,6 +263,14 @@ pub struct TaskSummary { /// String-replacement edits emitted by a Coding-mode task. /// The reaper applies each entry via tools::edit_file. pub code_edits: Vec, + /// Optional rewritten plan proposed by the slow agent. Wire + /// shape is `{steps: [...]}` via [`kres_core::PlanRewrite`] — + /// the caller merges it with the existing plan's metadata via + /// `apply_to` before handing it to `mgr.set_plan`. Populated + /// only when the slow agent emitted a `plan` field; only the + /// first slow call per top-level prompt is expected to set + /// this (see `RunContext.allow_plan_rewrite`). + pub plan: Option, } impl Orchestrator { @@ -289,10 +343,13 @@ impl Orchestrator { if let Some(sk) = &live_skills { cp = cp.with_skills(sk); } + if let Some(ref p) = ctx.plan { + cp = cp.with_plan(p); + } // §cache: split the envelope into a stable prefix - // (question + skills + previous_findings + parallel_lenses) - // and a per-round volatile tail (symbols + context + - // previously_fetched). The prefix cache-hits across + // (question + skills + previous_findings + parallel_lenses + // + plan) and a per-round volatile tail (symbols + context + // + previously_fetched). The prefix cache-hits across // rounds; the tail does not. let (prefix, suffix) = cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; prev_n_syms = symbols.len(); @@ -440,7 +497,7 @@ impl Orchestrator { _ = shutdown.cancelled() => { return Err(AgentError::Other("cancelled during fetch".into())); } - f = self.fetcher.fetch(&parsed.followups) => f?, + f = self.fetcher.fetch(&parsed.followups, ctx.plan.as_ref()) => f?, }; let got_syms = fetched.symbols.len(); let got_ctx = fetched.context.len(); @@ -493,6 +550,12 @@ impl Orchestrator { if let Some(sk) = &live_skills { slow_cp = slow_cp.with_skills(sk); } + if let Some(ref p) = ctx.plan { + slow_cp = slow_cp.with_plan(p); + } + if ctx.allow_plan_rewrite { + slow_cp = slow_cp.with_plan_rewrite_allowed(true); + } let (slow_prefix, slow_suffix) = slow_cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; let slow_logged = format!("{slow_prefix}{slow_suffix}"); let messages = vec![Message { @@ -644,6 +707,18 @@ impl Orchestrator { slow_parsed.code_edits, ), }; + // Only surface a slow-agent plan rewrite when this task is + // the first slow call for the top-level prompt. Later + // pipeline-driven tasks going through run_once_with_ctx + // (follow-ups) are NOT permitted to reshape the plan — the + // todo agent's per-turn reevaluation handles incremental + // updates, and letting every slow call rewrite would churn + // step ids mid-sweep and break the step_id→step linkage. + let slow_plan = if ctx.allow_plan_rewrite { + slow_parsed.plan + } else { + None + }; Ok(TaskSummary { analysis: slow_parsed.analysis, findings: findings_out, @@ -653,6 +728,7 @@ impl Orchestrator { mode: ctx.mode, code_output, code_edits, + plan: slow_plan, }) } } @@ -681,7 +757,7 @@ impl Orchestrator { // Gather once via fast+main (same loop as run_once, up to the // point where we'd call the slow agent). let (symbols, context, fast_rounds, live_skills) = - self.gather(prompt, shutdown).await?; + self.gather(prompt, ctx.plan.as_ref(), shutdown).await?; // Fan out N slow-agent calls in parallel. let mut futures = Vec::with_capacity(lenses.len()); @@ -723,6 +799,9 @@ impl Orchestrator { if let Some(sk) = &live_skills { lens_cp = lens_cp.with_skills(sk); } + if let Some(ref p) = ctx.plan { + lens_cp = lens_cp.with_plan(p); + } let (lens_prefix, lens_suffix) = lens_cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; let client = self.slow_client.clone(); let model = self.slow_model.clone(); @@ -826,6 +905,13 @@ impl Orchestrator { mode: kres_core::TaskMode::Analysis, code_output: Vec::new(), code_edits: Vec::new(), + // Lens fan-out runs N parallel slow calls; merging N + // plan rewrites would churn step ids. Analysis-mode + // plan rewrites flow through the todo-agent's per-turn + // reevaluation path (a97bff2) instead. Single-slow + // analysis tasks (lens count 0) still get plan rewrite + // via run_once_with_ctx above. + plan: None, }) } @@ -835,6 +921,7 @@ impl Orchestrator { pub async fn gather( &self, prompt: &str, + plan: Option<&kres_core::Plan>, shutdown: &Shutdown, ) -> Result<(Vec, Vec, u8, Option), AgentError> { let mut symbols: Vec = Vec::new(); @@ -881,6 +968,9 @@ impl Orchestrator { if let Some(sk) = &live_skills { cp = cp.with_skills(sk); } + if let Some(p) = plan { + cp = cp.with_plan(p); + } let (gp_prefix, gp_suffix) = cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; prev_n_syms = symbols.len(); prev_n_ctx = context.len(); @@ -956,7 +1046,7 @@ impl Orchestrator { } let fetched = tokio::select! { _ = shutdown.cancelled() => return Err(AgentError::Other("cancelled during fetch".into())), - f = self.fetcher.fetch(&parsed.followups) => f?, + f = self.fetcher.fetch(&parsed.followups, plan) => f?, }; symbols.extend(fetched.symbols); context.extend(fetched.context); @@ -1104,12 +1194,15 @@ mod tests { async fn null_fetcher_returns_empty() { let f = NullFetcher; let r = f - .fetch(&[Followup { - kind: "source".into(), - name: "x".into(), - reason: String::new(), - path: None, - }]) + .fetch( + &[Followup { + kind: "source".into(), + name: "x".into(), + reason: String::new(), + path: None, + }], + None, + ) .await .unwrap(); assert!(r.symbols.is_empty()); diff --git a/kres-agents/src/prompt.rs b/kres-agents/src/prompt.rs index 7f59b13..0812086 100644 --- a/kres-agents/src/prompt.rs +++ b/kres-agents/src/prompt.rs @@ -33,6 +33,23 @@ pub struct CodePrompt<'a> { pub parallel_lenses: Option<&'a Value>, #[serde(skip_serializing_if = "Option::is_none")] pub skills: Option<&'a Value>, + /// Plan produced by `define_plan` for the top-level prompt. + /// Forwarded to every fast and slow agent turn as a top-level + /// `plan` field so the agents see the operator-level + /// decomposition alongside their narrow per-task brief. The + /// plan is a stable-across-a-task payload, so callers place it + /// in the cached prefix half of `to_cached_split_json` for + /// free cache hits on round 2+. + #[serde(skip_serializing_if = "Option::is_none")] + pub plan: Option<&'a kres_core::Plan>, + /// When `Some(true)`, invites the slow agent to return a + /// top-level `plan` object in its response replacing the + /// current plan. Set on the first slow call per top-level + /// prompt (see `RunContext.allow_plan_rewrite`); left out + /// otherwise. Serialised as a top-level boolean so the + /// agent can trivially test for it. + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_rewrite_allowed: Option, } impl<'a> CodePrompt<'a> { @@ -45,9 +62,21 @@ impl<'a> CodePrompt<'a> { previous_findings: None, parallel_lenses: None, skills: None, + plan: None, + plan_rewrite_allowed: None, } } + pub fn with_plan(mut self, plan: &'a kres_core::Plan) -> Self { + self.plan = Some(plan); + self + } + + pub fn with_plan_rewrite_allowed(mut self, allowed: bool) -> Self { + self.plan_rewrite_allowed = Some(allowed); + self + } + pub fn with_symbols(mut self, symbols: &'a [Value]) -> Self { if !symbols.is_empty() { self.symbols = Some(symbols); diff --git a/kres-agents/src/prompts/goal.txt b/kres-agents/src/prompts/goal.txt index 11334fe..3937fd4 100644 --- a/kres-agents/src/prompts/goal.txt +++ b/kres-agents/src/prompts/goal.txt @@ -3,8 +3,8 @@ NOT run tools. You do NOT fetch data. You do NOT dispatch actions. You return ONE JSON object, matching the shape below, and nothing else. -You handle two tasks, selected by the request's top-level `"task"` -field: +You handle three tasks, selected by the request's top-level +`"task"` field: define_goal — given a user query, produce a concrete completion criterion AND classify the work mode. Return JSON @@ -52,18 +52,132 @@ field: and the operator can retry with explicit review or coding wording if they wanted more. + define_plan — given the operator's original prompt, the derived + goal, and the mode, produce an ordered list of 3-12 + concrete steps the pipeline will execute to reach + the goal. The plan is seen by every downstream + agent (main, fast, slow, goal judge), linked to + todos via `step_id`, persisted to session.json, and + reevaluated every turn by the todo agent — it is + load-bearing, not cosmetic. Return JSON only: + {"steps": [ + {"id": "s1", + "title": "short imperative, <= 80 chars", + "description": "one-to-two sentences of what + this step achieves; the goal + judge reads these"} + ]} + + COMMON RULES: + - `id` values must be unique within the plan and + be kebab-case SLUGS that describe the work, NOT + positional tags. Examples: `audit-ring-buffer- + init`, `walk-sqpoll-thread-path`, + `reproducer-makefile`. Do NOT emit `s1`, `s2`, + `step1`, or other position-bound ids — reorder- + ing a plan with positional ids silently + reassigns each id to a different step, and plan + rewrites later in the session can overload the + old id's meaning. Semantic slugs are stable + under reorder, survive rewrite cycles, and make + `/plan` output readable without looking up + titles. + - Step titles must name a CONCRETE artifact to + inspect or produce — a file path, a symbol, a + subsystem boundary, a code path, or a file the + code will emit. Titles like "audit memory safety" + or "review locking" are too vague: those lenses + run automatically on every slow call. + + ANALYSIS MODE — the single biggest pitfall is + restating the automatic lens fan-out (object + lifetime, memory, bounds, races, general + correctness) as plan steps. DO NOT. Those five + lenses run on every slow-agent call regardless of + plan. Decompose on a DIFFERENT axis instead, in + this order of preference: + + 1. Per file, when the prompt / goal lists a file + glob (e.g. `io_uring/*.c`) or an explicit file + set. Expand the glob into specific filenames + you reasonably infer from the subsystem — the + first task's slow agent gets a chance to correct + the plan once it sees the actual tree. + 2. Per function / symbol, when the prompt names a + small number of functions or mentions one. + 3. Per subsystem / code path (e.g. "SQPOLL thread + lifecycle", "ring-buffer indexing path", + "registered-buffer ref counting"), when the + prompt scopes to a subsystem but the files + aren't obvious. + 4. Only if none of the above apply, fall back to + investigation angles that are NOT the five + lenses — e.g. "walk the fault-injection error + paths" or "audit the uAPI surface for struct + padding leaks". + + Include a final "consolidate findings" step only + when the prompt asks for a single merged report; + the findings merger already runs automatically in + the pipeline otherwise. + + GENERIC MODE — 2-4 narrow steps that zero in on + the question. Same concreteness bar: name the + specific code paths / callers / invariants you + need to examine. + + CODING MODE — a file manifest: one step per + artifact to produce (reproducer.c, Makefile, + trigger.sh, selftest, patch file), plus any + required setup / validation step the operator + will need. Steps SHOULD name files that do not yet + exist. + + EXISTING PLAN — when the request carries an + `existing_plan` field, the operator already has a + plan in play. The new prompt is a continuation + (same subsystem, narrower follow-up, "look at s3 + again") OR a different topic (entirely new ask). + Continuation: KEEP step ids that still apply, + add/edit steps only where the new prompt needs + it. Preserving ids verbatim matters because todos + point at those ids via `step_id` and orphan when + the id vanishes. Different topic: emit a fresh + plan — do NOT try to preserve ids that no longer + fit, the todo-agent will re-link whatever todos + survive. Read the prompt + the existing plan + side-by-side before deciding which mode you are + in. + + FILE-NAMING POLICY — in coding mode you invent + filenames freely. In analysis / generic mode you + may list files only when you can infer them from + file globs, path prefixes, subsystem names, or + explicit file lists in the prompt or goal. Do NOT + invent files outside the prompt's scope. If you + are uncertain about specific filenames, scope the + steps to subsystems or code paths instead and let + the first slow pass refine. + check_goal — given the operator's original_prompt, the derived - goal, and the accumulated analysis, decide whether - the prompt has been satisfied. The goal is a summary - the main agent produced from the prompt; it may have + goal, the accumulated analysis, and optionally the + current `plan` object, decide whether the prompt + has been satisfied. The goal is a summary the main + agent produced from the prompt; it may have compressed or generalised intent. Treat the original_prompt as the ground-truth intent and the - goal as supporting context. If the prompt asks for a - sweep ("check every file", "analyse each function", - "review all X") AND the sweep-level analysis only - covers the first item, that is NOT met — list the - remaining items in `missing`. But see the NARROW - SUB-TASK rule below for when this does NOT apply. + goal as supporting context. When a `plan` field is + present, read its `steps` as a checklist: a step + whose title still names work the analysis has not + addressed is strong evidence that `met=false` with + that step added to `missing`. Steps clearly outside + the scope the analysis tackled do not force unmet. + If the prompt asks for a sweep ("check every file", + "analyse each function", "review all X") AND the + sweep-level analysis only covers the first item, + that is NOT met — list the remaining items in + `missing`. But see the NARROW SUB-TASK rule below + for when this does NOT apply. For coding-mode tasks the "analysis" field will contain notes + a summary of the code files produced; treat "code files exist that plausibly @@ -94,6 +208,7 @@ HARD CONSTRAINTS — violations are bugs: - Return the shape that matches the `"task"` field: "task":"define_goal" → {"goal": "...", "mode": "analysis"|"generic"|"coding"} + "task":"define_plan" → {"steps": [{"id": "...", "title": "...", "description": "..."}]} "task":"check_goal" → {"met": ..., "reason": "...", "missing": [...]} Returning the other task's shape is a bug. The `mode` field on define_goal is REQUIRED — never omit it. When unsure, emit diff --git a/kres-agents/src/prompts/todo.txt b/kres-agents/src/prompts/todo.txt index 4b24721..6a4c52d 100644 --- a/kres-agents/src/prompts/todo.txt +++ b/kres-agents/src/prompts/todo.txt @@ -4,12 +4,38 @@ and mark status. You have NO tools and do NO research. You receive a single user message whose JSON carries: task='update_todo', completed_query, analysis_summary, new_followups, current_todo, and -possibly lenses. +optionally lenses and plan. Return JSON ONLY, no fences, no preamble, no commentary: -{"todo": [, ...]} +{"todo": [, ...], "plan": } -Per-item schema: {name, type, status, reason, depends_on}. +Per-item schema: {name, type, status, reason, depends_on, step_id}. + +The top-level `plan` field is OPTIONAL. Wire shape: +`"plan": {"steps": [...]}` — emit ONLY the `steps` array. The +pipeline keeps the existing plan's `prompt`, `goal`, `mode`, and +`created_at` verbatim, so you cannot and need not set them. +Include a rewrite only when the current plan is materially wrong +— steps duplicate the automatic lens fan-out, a concrete step the +prompt requires is missing, a step is too vague to track status +against, or two steps have collapsed into one. When in doubt, +omit the field and keep the operator's existing plan. Keep step +ids stable across rewrites whenever the step's intent survives; +when a step's MEANING changes, use a new id instead of +overloading the old one. New ids MUST be kebab-case slugs +describing the work (e.g. `audit-ring-buffer-init`). Never emit +positional tags like `s1`/`s2`. Every emitted todo's `step_id` +must reference the NEW plan's ids, not ids you removed. + +When the request carries a `plan` field (list of {id, title, +description} steps), set `step_id` on every emitted todo to the +best-matching plan step id — that is how the plan's per-step +status stays in sync with what actually ran. Match on target +file / symbol / subsystem / angle, not just keyword overlap; +leave `step_id` as the empty string when nothing fits, and +preserve the existing `step_id` on current_todo items unless +the new analysis proves the item was executing a different step. +Only use step ids the `plan.steps` list actually contains. HARD CONSTRAINTS: - Do NOT emit or tags. You have no dispatcher; diff --git a/kres-agents/src/response.rs b/kres-agents/src/response.rs index 0a43e33..d47130d 100644 --- a/kres-agents/src/response.rs +++ b/kres-agents/src/response.rs @@ -48,6 +48,18 @@ pub struct CodeResponse { /// primitive: `{file_path, old_string, new_string, replace_all}`. /// The reaper applies each entry via `tools::edit_file`. pub code_edits: Vec, + /// Optional rewritten plan. The slow agent is permitted to + /// emit a top-level `plan` field when (a) it's the first slow + /// call for the operator's top-level prompt and (b) the code + /// it just inspected shows the existing plan is materially + /// wrong. The wire shape is `{steps: [...]}` (only the steps + /// are mutable — prompt/goal/mode/created_at inherit from the + /// existing plan via `PlanRewrite::apply_to` at the apply + /// site); parsing just the steps means a forgotten metadata + /// field in the LLM reply does NOT silently drop the rewrite. + /// `None` means "keep the existing plan", which is the common + /// case. + pub plan: Option, /// Which parse strategy won — used for diagnostics. pub strategy: ParseStrategy, } @@ -84,6 +96,8 @@ struct RawResponse { code_output: Value, #[serde(default)] code_edits: Value, + #[serde(default)] + plan: Value, } pub fn parse_code_response(text: &str) -> CodeResponse { @@ -145,6 +159,7 @@ pub fn parse_code_response(text: &str) -> CodeResponse { ready_for_slow: false, code_output: vec![], code_edits: vec![], + plan: None, strategy: ParseStrategy::RawText, } } @@ -190,10 +205,30 @@ fn into_code_response(r: RawResponse, _original: &str, strategy: ParseStrategy) ready_for_slow: matches!(r.ready_for_slow, Value::Bool(true)), code_output: value_to_code_output(r.code_output), code_edits: value_to_code_edits(r.code_edits), + plan: value_to_plan(r.plan), strategy, } } +fn value_to_plan(v: Value) -> Option { + match v { + Value::Null => None, + other => { + // Only the `steps` field is consumed; any other fields + // the LLM stuffs in (prompt, goal, mode, created_at) + // are ignored. An empty-steps rewrite is indistinguish- + // able from "no rewrite", so drop it. + let rewrite: kres_core::PlanRewrite = + serde_json::from_value(other).ok()?; + if rewrite.steps.is_empty() { + None + } else { + Some(rewrite) + } + } + } +} + fn value_to_code_edits(v: Value) -> Vec { let Value::Array(items) = v else { return vec![]; diff --git a/kres-agents/src/todo_agent.rs b/kres-agents/src/todo_agent.rs index 7254892..6179698 100644 --- a/kres-agents/src/todo_agent.rs +++ b/kres-agents/src/todo_agent.rs @@ -52,9 +52,33 @@ pub struct TodoClient { struct TodoUpdateResponse { #[serde(default)] todo: Value, + /// Optional rewritten plan the agent wants to substitute. Agents + /// may emit this when the existing plan no longer matches the + /// work actually being done (e.g. a step is complete and the + /// sweep needs a new axis). Absent / null leaves the manager's + /// current plan in place. + /// + /// Wire shape is `{steps: [...]}` (only the steps are mutable); + /// the caller merges with the existing plan's metadata via + /// `kres_core::PlanRewrite::apply_to` at the apply site. Parsing + /// just the steps means a forgotten metadata field cannot + /// silently drop the rewrite. + #[serde(default)] + plan: Option, +} + +/// Combined return value of `update_todo_via_agent*`: the reconciled +/// todo list plus an optional rewritten plan. `plan` is a rewrite +/// (steps-only); the caller applies it against the existing plan. +#[derive(Debug, Clone, Default)] +pub struct TodoUpdate { + pub todo: Vec, + pub plan: Option, } -/// Run the todo agent. Returns an updated list. Matches +/// Run the todo agent. Returns an updated todo list plus an +/// optionally-rewritten plan. Matches +#[allow(clippy::too_many_arguments)] pub async fn update_todo_via_agent( tc: &TodoClient, completed_query: &str, @@ -62,7 +86,8 @@ pub async fn update_todo_via_agent( new_followups: &[Value], current_todo: &[TodoItem], lenses: &[LensSpec], -) -> Result, AgentError> { + plan: Option<&kres_core::Plan>, +) -> Result { update_todo_via_agent_with_logger( tc, completed_query, @@ -70,6 +95,7 @@ pub async fn update_todo_via_agent( new_followups, current_todo, lenses, + plan, None, ) .await @@ -85,8 +111,9 @@ pub async fn update_todo_via_agent_with_logger( new_followups: &[Value], current_todo: &[TodoItem], lenses: &[LensSpec], + plan: Option<&kres_core::Plan>, logger: Option>, -) -> Result, AgentError> { +) -> Result { // --- Prepare inputs ------------------------------------------------ let mut todo_list = current_todo.to_vec(); assign_ids(&mut todo_list); @@ -117,9 +144,22 @@ pub async fn update_todo_via_agent_with_logger( if !lens_payload.is_empty() { request.insert("lenses".into(), json!(lens_payload)); } + // Ship the current plan (if any) so the agent can attach + // `step_id` to each emitted todo; `build_instructions` flips + // its plan-linking paragraph on when has_plan is true. + let has_plan = if let Some(p) = plan { + if let Ok(v) = serde_json::to_value(p) { + request.insert("plan".into(), v); + true + } else { + false + } + } else { + false + }; request.insert( "instructions".into(), - json!(build_instructions(!lens_payload.is_empty())), + json!(build_instructions(!lens_payload.is_empty(), has_plan)), ); let request_text = serde_json::to_string_pretty(&Value::Object(request))?; @@ -148,7 +188,10 @@ pub async fn update_todo_via_agent_with_logger( Ok(r) => r, Err(e) => { tracing::warn!(target: "kres_agents", "todo agent call failed: {e}; falling back"); - return Ok(fallback_dedup(&todo_list, new_followups)); + return Ok(TodoUpdate { + todo: fallback_dedup(&todo_list, new_followups), + plan: None, + }); } }; let text = extract_text(&resp); @@ -167,12 +210,24 @@ pub async fn update_todo_via_agent_with_logger( } // --- Parse response ------------------------------------------------ - let parsed: Vec = match parse_todo_response(&text) { - Some(v) => v, - None => { - tracing::warn!(target: "kres_agents", "todo agent returned no parseable list; falling back"); - return Ok(fallback_dedup(&todo_list, new_followups)); - } + // Try the combined (todo + plan) envelope first so the agent's + // optional plan rewrite survives; fall back to the todo-only + // parser for responses that only carry the todo array. + let (parsed, returned_plan) = match parse_todo_update_full(&text) { + Some((todo, plan)) => (todo, plan), + None => match parse_todo_response(&text) { + Some(v) => (v, None), + None => { + tracing::warn!( + target: "kres_agents", + "todo agent returned no parseable list; falling back" + ); + return Ok(TodoUpdate { + todo: fallback_dedup(&todo_list, new_followups), + plan: None, + }); + } + }, }; // --- Reconcile with existing done items --------------------------- @@ -268,7 +323,10 @@ pub async fn update_todo_via_agent_with_logger( result.extend(done_final); result.extend(preserved); result.extend(filtered_pending); - Ok(result) + Ok(TodoUpdate { + todo: result, + plan: returned_plan, + }) } fn todo_to_payload(t: &TodoItem) -> Value { @@ -291,6 +349,9 @@ fn todo_to_payload(t: &TodoItem) -> Value { if !t.coverage.is_empty() { obj.insert("coverage".into(), json!(t.coverage)); } + if !t.step_id.is_empty() { + obj.insert("step_id".into(), json!(t.step_id)); + } Value::Object(obj) } @@ -572,6 +633,11 @@ fn followup_to_todo(fu: &Value) -> Result { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); + let step_id = fu + .get("step_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); Ok(TodoItem { name, kind, @@ -580,15 +646,68 @@ fn followup_to_todo(fu: &Value) -> Result { depends_on: Vec::new(), coverage: String::new(), id: String::new(), + step_id, }) } -fn build_instructions(has_lenses: bool) -> String { +fn build_instructions(has_lenses: bool, has_plan: bool) -> String { let mut s = String::from( "Update the todo list. Return JSON only:\n\ {\"todo\": [{\"id\":\"ID\",\"type\":\"T\",\"name\":\"N\",\"reason\":\"R\",\ - \"status\":\"pending|done\",\"coverage\":\"C\",\"depends_on\":[\"ID1\",\"ID2\"]}]}\n\n", + \"status\":\"pending|done\",\"coverage\":\"C\",\"depends_on\":[\"ID1\",\"ID2\"],\ + \"step_id\":\"PLAN_STEP_ID_OR_EMPTY\"}]}\n\n", ); + if has_plan { + s.push_str( + "PLAN LINKAGE — a `plan` field is present with `steps:[{id,\ + title,description}]`. For EVERY todo item you emit in the \ + output (done or pending), set `step_id` to the id of the \ + plan step whose title/description best matches the todo's \ + target. Match on file, symbol, subsystem, or investigation \ + angle — not just keyword overlap. If NO step is a clear \ + fit, set `step_id` to the empty string. Do not invent step \ + ids; only use ids listed under `plan.steps`. Keep any \ + step_id already set on a current_todo item unless the new \ + analysis proves the item was actually executing a \ + different step.\n\n", + ); + s.push_str( + "PLAN REEVALUATION — you MAY also return a top-level \ + `plan` field alongside `todo` to rewrite the plan. Do \ + this ONLY when the analysis shows the current plan is \ + materially wrong: a step is too vague to track, a step \ + duplicates the pipeline's automatic lens fan-out and \ + produces no new signal, a new concrete step is needed \ + (e.g. a specific subsystem the prompt's sweep clearly \ + requires but the planner missed), or a step's work is \ + fully subsumed by another. Keep the plan STABLE when it \ + is still serviceable — churning step ids breaks the \ + step_id links on existing todos and wastes tokens.\n\ + Wire shape: `\"plan\": {\"steps\": [...]}`. Emit ONLY \ + the `steps` array. The pipeline keeps the existing \ + plan's `prompt`, `goal`, `mode`, and `created_at` \ + verbatim — you cannot and need not set them.\n\ + When you do rewrite:\n\ + - Prefer KEEPING existing step ids when the step's \ + intent survives (even if title/description change) so \ + the linked todos do not orphan.\n\ + - When a step's MEANING changes, assign a NEW id \ + instead of overloading the old one. The step_id → \ + semantics contract is how this module's todo-linker \ + stays honest; overloading it poisons the link.\n\ + - New ids MUST be kebab-case slugs that describe the \ + work (e.g. `audit-ring-buffer-init`). Never use \ + positional tags like `s1`/`s2`; they get accidentally \ + reassigned when steps reorder.\n\ + - Every step you emit MUST have id, title, and status. \ + Description and todo_ids are optional.\n\ + - After rewriting, set step_id on every emitted todo to \ + an id from the NEW plan — do not reference ids you \ + just removed.\n\ + Omit the `plan` field entirely when no rewrite is \ + warranted — that is the common case.\n\n", + ); + } s.push_str( "REPRIORITIZE — every call, not just when new items arrive:\n\ - Sort all pending items so the one MOST LIKELY to surface a \ @@ -664,6 +783,54 @@ fn build_instructions(has_lenses: bool) -> String { s } +/// Extract both the `todo` array and an optional rewritten `plan` +/// from the todo-agent response. Mirrors `parse_todo_response`'s +/// parse-then-brace-match discipline but preserves the full +/// envelope. Returns `Some((todo, Option))` when the response +/// carried a parseable `todo` field; returns `None` when the +/// envelope itself couldn't be parsed (callers fall back to the +/// todo-only parser, which tries harder on malformed replies). +fn parse_todo_update_full( + text: &str, +) -> Option<(Vec, Option)> { + if let Ok(r) = serde_json::from_str::(text) { + if let Some(items) = todo_list_from_value(r.todo) { + return Some((items, r.plan)); + } + } + let bytes = text.as_bytes(); + let mut start: Option = None; + let mut depth: i32 = 0; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'{' => { + if depth == 0 { + start = Some(i); + } + depth += 1; + } + b'}' => { + depth -= 1; + if depth == 0 { + if let Some(s) = start.take() { + if let Ok(r) = + serde_json::from_str::(&text[s..=i]) + { + if let Some(items) = todo_list_from_value(r.todo) { + return Some((items, r.plan)); + } + } + } + } + } + _ => {} + } + i += 1; + } + None +} + /// Extract the `todo` array from the agent's response text. Tries /// strict JSON first, then brace-matching. pub fn parse_todo_response(text: &str) -> Option> { @@ -828,6 +995,7 @@ mod tests { depends_on: Vec::new(), coverage: String::new(), id: String::new(), + step_id: String::new(), }]; let new_fu = vec![json!({ "type": "investigate", diff --git a/kres-core/src/lib.rs b/kres-core/src/lib.rs index dd20658..f26d2fe 100644 --- a/kres-core/src/lib.rs +++ b/kres-core/src/lib.rs @@ -21,6 +21,8 @@ pub mod io; pub mod lens; pub mod log; pub mod mode; +pub mod plan; +pub mod session_state; pub mod shrink; pub mod shutdown; pub mod task; @@ -32,6 +34,8 @@ pub use findings::{Finding, FindingsFile, FindingsStore, Severity}; pub use lens::LensSpec; pub use log::{LoggedUsage, TurnLogger}; pub use mode::{CodeEdit, CodeFile, TaskMode}; +pub use plan::{Plan, PlanRewrite, PlanStep, PlanStepStatus}; +pub use session_state::{SessionState, SessionStateError}; pub use shrink::{ estimate_tokens, finding_char_size, fit_payload, shrink_findings_to_budget, shrink_last_user_message, total_char_size, diff --git a/kres-core/src/plan.rs b/kres-core/src/plan.rs new file mode 100644 index 0000000..0f96a5a --- /dev/null +++ b/kres-core/src/plan.rs @@ -0,0 +1,530 @@ +//! Plan: a pre-computed breakdown of the operator's top-level +//! prompt into concrete steps the pipeline intends to execute. +//! +//! A plan is produced after every operator-typed prompt (by +//! `kres_agents::define_plan` running right after `define_goal`) +//! and lives alongside the todo list on the [`crate::TaskManager`]. +//! Three other writers can ALSO reshape it while a task runs: +//! - the first slow-agent call per top-level prompt, when the +//! operator-typed task has `allow_plan_rewrite=true`; +//! - the todo-agent, on every completed task — it may return a +//! rewritten `plan` alongside the updated todo list; +//! - the goal-not-met todo-agent injection, for the same reason. +//! +//! Linkage is bidirectional. Each [`PlanStep`] carries `todo_ids` +//! pointing DOWN at todos (populated rarely; mainly for tests and +//! persisted pre-step_id state); each [`crate::TodoItem`] carries +//! `step_id` pointing UP at a step (populated by the todo-agent). +//! A step is `Done` once every linked todo is terminal. +//! +//! Plans are persisted into `/session.json` on mutation +//! so a Ctrl-C / `--turns` cap / crash can be resumed on the next +//! invocation pointed at the same results directory. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::mode::TaskMode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PlanStepStatus { + Pending, + InProgress, + Done, + Skipped, +} + +impl PlanStepStatus { + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Done | Self::Skipped) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanStep { + /// Stable kebab-case slug id (e.g. "audit-ring-buffer-init"). + /// Defaulted on deserialize so a forgotten `id` in an LLM + /// reply does not fail the whole rewrite; `normalize_steps` + /// synthesises a slug from `title` when this is empty. + #[serde(default)] + pub id: String, + /// Short imperative title ("audit release path in foo()"). + /// Defaulted on deserialize for the same reason; rows with + /// empty titles are filtered by `normalize_steps`. + #[serde(default)] + pub title: String, + /// Free-form prose describing what success looks like for this + /// step. Consumed by the goal judge. + #[serde(default)] + pub description: String, + #[serde(default = "default_pending")] + pub status: PlanStepStatus, + /// IDs (or names, when id is empty) of the todo items that + /// execute this step. A step flips to `Done` when every linked + /// todo is terminal. + #[serde(default)] + pub todo_ids: Vec, +} + +fn default_pending() -> PlanStepStatus { + PlanStepStatus::Pending +} + +impl PlanStep { + pub fn new(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + description: String::new(), + status: PlanStepStatus::Pending, + todo_ids: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plan { + /// The operator's raw prompt that produced this plan. Stored so + /// a resumed session can reconstruct context without re-prompting. + pub prompt: String, + /// The goal-judge's completion criterion (define_goal output). + pub goal: String, + pub mode: TaskMode, + #[serde(default)] + pub steps: Vec, + pub created_at: DateTime, +} + +/// Wire-format for rewrites emitted by the slow agent or the +/// todo agent. LLMs forget fields all the time; accepting only +/// `{steps: [...]}` means the plan's identifying metadata +/// (`prompt`, `goal`, `mode`, `created_at`) cannot be accidentally +/// clobbered. The caller merges a `PlanRewrite` with the existing +/// plan via `apply_to`. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct PlanRewrite { + #[serde(default)] + pub steps: Vec, +} + +impl PlanRewrite { + /// Build a full `Plan` by taking the rewrite's steps and + /// inheriting identifying metadata from `prior`. When `prior` + /// is `None` (rewrite received with no current plan — should + /// not happen in the normal flow, but defensive), returns a + /// Plan with empty prompt / goal and a fresh timestamp. + /// + /// The rewrite's steps are passed through `normalize_steps` + /// before being placed on the Plan: empty-title rows filtered, + /// missing or duplicate ids replaced with a slug derived from + /// the title. The LLM cannot corrupt the plan's step-id + /// invariants no matter how sloppy its reply is. + pub fn apply_to(self, prior: Option<&Plan>) -> Plan { + let steps = normalize_steps(self.steps); + match prior { + Some(p) => Plan { + prompt: p.prompt.clone(), + goal: p.goal.clone(), + mode: p.mode, + steps, + created_at: p.created_at, + }, + None => Plan { + prompt: String::new(), + goal: String::new(), + mode: TaskMode::default(), + steps, + created_at: Utc::now(), + }, + } + } +} + +/// Filter empty-title rows and synthesise any missing or collided +/// step ids from a kebab-case slug of the title. Runs the same +/// invariants the `define_plan` path enforces so rewrite replies +/// from the slow agent or todo agent cannot produce a plan with +/// empty ids, duplicate ids, or titleless rows. +/// +/// - Empty title → filtered. Never survives to the Plan. +/// - Empty id OR collision with an earlier row → synthesise from +/// `slugify_step_id(title)`, then walk `-2`, `-3`, … to find a +/// free slot. Titleless slug falls back to `step-` where N +/// is the 1-based position among kept rows. +/// - Non-empty unique id → kept verbatim. This preserves operator +/// or planner intent when the LLM cooperated. +pub fn normalize_steps(steps: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(steps.len()); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut synth_n: usize = 0; + for mut s in steps.into_iter() { + let title = s.title.trim().to_string(); + if title.is_empty() { + continue; + } + s.title = title; + synth_n += 1; + let id = s.id.trim().to_string(); + s.id = if !id.is_empty() && seen.insert(id.clone()) { + id + } else { + let base = slugify_step_id(&s.title); + let base = if base.is_empty() { + format!("step-{synth_n}") + } else { + base + }; + let mut candidate = base.clone(); + let mut suffix = 1u32; + while !seen.insert(candidate.clone()) { + suffix += 1; + candidate = format!("{base}-{suffix}"); + } + candidate + }; + out.push(s); + } + out +} + +/// Produce a kebab-case slug from a step title, truncated to 60 +/// chars. Keeps ASCII letters / digits and collapses everything +/// else into single `-` separators; strips leading/trailing `-`; +/// lowercases. Returns an empty string when the title contains +/// no slug-able characters — callers fall back to `step-` in +/// that case. +pub fn slugify_step_id(title: &str) -> String { + let mut out = String::with_capacity(title.len()); + let mut last_dash = true; // suppress leading `-` + for ch in title.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + last_dash = false; + } else if !last_dash { + out.push('-'); + last_dash = true; + } + if out.len() >= 60 { + break; + } + } + while out.ends_with('-') { + out.pop(); + } + out +} + +impl Plan { + pub fn new(prompt: impl Into, goal: impl Into, mode: TaskMode) -> Self { + Self { + prompt: prompt.into(), + goal: goal.into(), + mode, + steps: Vec::new(), + created_at: Utc::now(), + } + } + + /// Flip a step's status by id. + pub fn mark_step(&mut self, id: &str, status: PlanStepStatus) -> bool { + if let Some(s) = self.steps.iter_mut().find(|s| s.id == id) { + s.status = status; + true + } else { + false + } + } + + /// Recompute step statuses from a linked todo list. This is a + /// full rederive — not a one-way "promote only" update — because + /// the TaskManager drain path can flip InProgress todos back to + /// Pending (ctrl-c, --turns cap); a step whose todos have all + /// regressed to Pending must likewise regress from InProgress + /// back to Pending so the plan does not lie about what is still + /// running. + /// + /// Linkage direction: a todo points UP at a plan step via + /// `TodoItem.step_id`; the step can also point DOWN at todo ids + /// via `PlanStep.todo_ids`. This function accepts either. The + /// `step_id` direction is easier for the todo-agent to populate + /// (one field per emitted todo) and the preferred mechanism + /// going forward; `todo_ids` stays as a compatibility path for + /// plans that carry pre-populated links (tests, persisted + /// pre-step_id state). + /// + /// Rules, in order of precedence: + /// - step is already terminal (`Done`/`Skipped`) → leave alone + /// - no linkage resolves to any todo → leave alone (planner + /// hasn't wired up the links yet) + /// - every linked todo is terminal → `Done` + /// - any linked todo is `InProgress` → `InProgress` + /// - otherwise → `Pending` + pub fn sync_from_todo(&mut self, todo: &[crate::TodoItem]) { + for step in self.steps.iter_mut() { + if step.status.is_terminal() { + continue; + } + // Collect linked todos via step_id first (todo → step); + // then union with whatever `step.todo_ids` claims, so + // both linkage directions contribute. Dedupe by todo + // pointer identity using the index, since a todo can + // only appear once in the input slice. + let mut linked_idx: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for (n, i) in todo.iter().enumerate() { + if !i.step_id.is_empty() && i.step_id == step.id { + linked_idx.insert(n); + } + } + for tid in &step.todo_ids { + if let Some(n) = todo.iter().position(|i| { + (!i.id.is_empty() && i.id == *tid) || i.name == *tid + }) { + linked_idx.insert(n); + } + } + if linked_idx.is_empty() { + continue; + } + let linked: Vec<&crate::TodoItem> = + linked_idx.iter().map(|n| &todo[*n]).collect(); + let all_terminal = linked.iter().all(|i| { + matches!( + i.status, + crate::TodoStatus::Done | crate::TodoStatus::Skipped + ) + }); + if all_terminal { + step.status = PlanStepStatus::Done; + continue; + } + let any_inprogress = linked + .iter() + .any(|i| i.status == crate::TodoStatus::InProgress); + step.status = if any_inprogress { + PlanStepStatus::InProgress + } else { + PlanStepStatus::Pending + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::todo::{TodoItem, TodoStatus}; + + #[test] + fn plan_serde_roundtrip() { + let mut p = Plan::new("review foo", "every fn audited", TaskMode::Analysis); + p.steps.push(PlanStep::new("s1", "audit foo()")); + p.steps[0].todo_ids.push("t1".into()); + let s = serde_json::to_string(&p).unwrap(); + let back: Plan = serde_json::from_str(&s).unwrap(); + assert_eq!(back.prompt, "review foo"); + assert_eq!(back.steps.len(), 1); + assert_eq!(back.steps[0].status, PlanStepStatus::Pending); + } + + #[test] + fn step_status_terminal() { + assert!(PlanStepStatus::Done.is_terminal()); + assert!(PlanStepStatus::Skipped.is_terminal()); + assert!(!PlanStepStatus::Pending.is_terminal()); + assert!(!PlanStepStatus::InProgress.is_terminal()); + } + + #[test] + fn sync_from_todo_marks_done_when_all_linked_terminal() { + let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "one"); + step.todo_ids = vec!["a".into(), "b".into()]; + p.steps.push(step); + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::Done; + let mut b = TodoItem::new("b", "investigate"); + b.status = TodoStatus::Skipped; + p.sync_from_todo(&[a, b]); + assert_eq!(p.steps[0].status, PlanStepStatus::Done); + } + + #[test] + fn sync_from_todo_inprogress_when_any_running() { + let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "one"); + step.todo_ids = vec!["a".into(), "b".into()]; + p.steps.push(step); + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::InProgress; + let b = TodoItem::new("b", "investigate"); + p.sync_from_todo(&[a, b]); + assert_eq!(p.steps[0].status, PlanStepStatus::InProgress); + } + + #[test] + fn sync_from_todo_leaves_pending_alone() { + let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "one"); + step.todo_ids = vec!["a".into()]; + p.steps.push(step); + let a = TodoItem::new("a", "investigate"); + p.sync_from_todo(&[a]); + assert_eq!(p.steps[0].status, PlanStepStatus::Pending); + } + + #[test] + fn sync_from_todo_regresses_stale_inprogress_to_pending() { + // After TaskManager::reset_in_progress_to_pending flips the + // linked todos back to Pending, a step that was InProgress + // must also regress — otherwise the live plan lies about + // what is still running. + let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "one"); + step.status = PlanStepStatus::InProgress; + step.todo_ids = vec!["a".into()]; + p.steps.push(step); + let a = TodoItem::new("a", "investigate"); // default Pending + p.sync_from_todo(&[a]); + assert_eq!(p.steps[0].status, PlanStepStatus::Pending); + } + + #[test] + fn sync_from_todo_links_via_step_id() { + // New linkage direction: todo.step_id points up at the plan + // step. sync_from_todo must find the linked todo without any + // entry in step.todo_ids. + let mut p = Plan::new("p", "g", TaskMode::Analysis); + p.steps.push(PlanStep::new("s1", "audit foo")); + let mut t = TodoItem::new("audit-foo", "investigate"); + t.step_id = "s1".into(); + t.status = TodoStatus::Done; + p.sync_from_todo(&[t]); + assert_eq!(p.steps[0].status, PlanStepStatus::Done); + } + + #[test] + fn sync_from_todo_unions_step_id_and_todo_ids() { + // Both linkage directions must contribute. Step.todo_ids + // claims todo "a"; todo "b" points back via step_id. Step + // is Done only when BOTH reach terminal status. + let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "audit"); + step.todo_ids = vec!["a".into()]; + p.steps.push(step); + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::Done; + let mut b = TodoItem::new("b", "investigate"); + b.step_id = "s1".into(); + b.status = TodoStatus::InProgress; + p.sync_from_todo(&[a, b]); + assert_eq!(p.steps[0].status, PlanStepStatus::InProgress); + } + + #[test] + fn slugify_step_id_samples() { + assert_eq!( + slugify_step_id("Audit ring buffer init"), + "audit-ring-buffer-init" + ); + assert_eq!( + slugify_step_id("Walk io_uring/fs.c fault paths"), + "walk-io-uring-fs-c-fault-paths" + ); + assert_eq!(slugify_step_id(" --- "), ""); + assert_eq!(slugify_step_id("one"), "one"); + } + + fn step(id: &str, title: &str) -> PlanStep { + PlanStep::new(id, title) + } + + #[test] + fn normalize_steps_filters_empty_titles() { + let out = normalize_steps(vec![ + step("keep-id", ""), + step("", "Kept title"), + ]); + assert_eq!(out.len(), 1); + assert_eq!(out[0].id, "kept-title"); + } + + #[test] + fn normalize_steps_synthesises_and_dedup_ids() { + let out = normalize_steps(vec![ + step("", "Audit foo"), + step("", "Audit foo"), + step("audit-foo", "Audit bar"), + ]); + assert_eq!(out.len(), 3); + assert_eq!(out[0].id, "audit-foo"); + // Second row with empty id slugs to "audit-foo" which is + // taken, so walks to "audit-foo-2". + assert_eq!(out[1].id, "audit-foo-2"); + // Third row has a non-empty id "audit-foo" but that's now + // taken; slugs to "audit-bar" which is free. + assert_eq!(out[2].id, "audit-bar"); + } + + #[test] + fn normalize_steps_titleless_slug_falls_back_to_step_n() { + let out = normalize_steps(vec![step("", "!!!"), step("", "@@@")]); + assert_eq!(out.len(), 2); + assert_eq!(out[0].id, "step-1"); + assert_eq!(out[1].id, "step-2"); + } + + #[test] + fn plan_step_deserialises_with_missing_id_and_title() { + // Regression guard: when an LLM forgets fields the step + // still parses — earlier behaviour was a hard fail that + // silently dropped the whole rewrite. + let s: PlanStep = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(s.id, ""); + assert_eq!(s.title, ""); + assert_eq!(s.status, PlanStepStatus::Pending); + } + + #[test] + fn apply_to_inherits_prior_metadata_and_normalises_steps() { + let prior = Plan::new("review fs", "find bugs", TaskMode::Analysis); + // The rewrite forgot the id on one step and left a title + // blank on another — without normalisation, apply_to would + // land a broken plan. + let rewrite = PlanRewrite { + steps: vec![step("", "Audit foo"), step("bad", "")], + }; + let built = rewrite.apply_to(Some(&prior)); + assert_eq!(built.prompt, "review fs"); + assert_eq!(built.goal, "find bugs"); + assert_eq!(built.mode, TaskMode::Analysis); + assert_eq!(built.steps.len(), 1); + assert_eq!(built.steps[0].id, "audit-foo"); + } + + #[test] + fn apply_to_with_no_prior_produces_default_metadata() { + let rewrite = PlanRewrite { + steps: vec![step("", "Only step")], + }; + let built = rewrite.apply_to(None); + assert!(built.prompt.is_empty()); + assert!(built.goal.is_empty()); + assert_eq!(built.mode, TaskMode::default()); + assert_eq!(built.steps.len(), 1); + } + + #[test] + fn sync_from_todo_skips_terminal_steps() { + let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "one"); + step.status = PlanStepStatus::Skipped; + step.todo_ids = vec!["a".into()]; + p.steps.push(step); + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::InProgress; + p.sync_from_todo(&[a]); + assert_eq!(p.steps[0].status, PlanStepStatus::Skipped); + } +} diff --git a/kres-core/src/session_state.rs b/kres-core/src/session_state.rs new file mode 100644 index 0000000..5fc41ed --- /dev/null +++ b/kres-core/src/session_state.rs @@ -0,0 +1,331 @@ +//! Persisted session state: plan + todo + deferred + counters, +//! written to `/session.json` so an interrupted session +//! can be resumed on the next invocation pointed at the same +//! results directory. +//! +//! Two invariants: +//! +//! 1. **Atomic writes.** Every persist goes through a tmp-file + +//! fsync + rename dance (mirrors [`crate::findings`] write +//! discipline) so a crash mid-write cannot leave half a JSON +//! blob behind for the next session to choke on. +//! +//! 2. **InProgress is not durable.** When loading a snapshot we +//! flip every `TodoStatus::InProgress` todo back to `Pending`. +//! An in-progress task belonged to a process that no longer +//! exists; the only honest thing to do is re-queue it for the +//! resumed session to pick up. The same rule applies to +//! [`crate::PlanStepStatus::InProgress`]. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::plan::{Plan, PlanStepStatus}; +use crate::todo::{TodoItem, TodoStatus}; + +#[derive(Debug, Error)] +pub enum SessionStateError { + #[error("i/o: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +/// Versioned snapshot of everything needed to resume a session. +/// +/// The `version` field is for forward compat: future schema +/// changes bump it and loaders decide how to migrate. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionState { + #[serde(default = "default_version")] + pub version: u32, + /// Last operator prompt submitted. Useful for `--resume` + /// reporting; not required for correctness. + #[serde(default)] + pub last_prompt: Option, + #[serde(default)] + pub plan: Option, + #[serde(default)] + pub todo: Vec, + /// Items parked by `/stop`, goal-met, or `--turns` drain. + #[serde(default)] + pub deferred: Vec, + /// Counter against `--turns N`. Persisted so a resumed session + /// picks up the cap rather than starting over at 0. + #[serde(default)] + pub completed_run_count: u32, +} + +fn default_version() -> u32 { + 1 +} + +impl Default for SessionState { + fn default() -> Self { + Self { + version: default_version(), + last_prompt: None, + plan: None, + todo: Vec::new(), + deferred: Vec::new(), + completed_run_count: 0, + } + } +} + +impl SessionState { + /// Default filename inside a results dir. + pub const FILENAME: &'static str = "session.json"; + + pub fn path_in(dir: &Path) -> PathBuf { + dir.join(Self::FILENAME) + } + + /// Load a previously-persisted snapshot. Returns `Ok(None)` + /// when the file does not exist (fresh session); `Err` only on + /// real I/O or parse failures. + /// + /// Post-load hygiene: any `InProgress` todo / plan step is + /// flipped to `Pending`, since its prior executor is gone. + pub fn load(path: &Path) -> Result, SessionStateError> { + match std::fs::read_to_string(path) { + Ok(s) => { + let mut state: Self = serde_json::from_str(&s)?; + state.normalise_inprogress(); + Ok(Some(state)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Flip every in-progress todo and plan step back to pending. + pub fn normalise_inprogress(&mut self) { + for item in self.todo.iter_mut() { + if item.status == TodoStatus::InProgress { + item.status = TodoStatus::Pending; + } + } + for item in self.deferred.iter_mut() { + if item.status == TodoStatus::InProgress { + item.status = TodoStatus::Pending; + } + } + if let Some(p) = self.plan.as_mut() { + for step in p.steps.iter_mut() { + if step.status == PlanStepStatus::InProgress { + step.status = PlanStepStatus::Pending; + } + } + } + } + + /// Persist to `path` via tmp-file + fsync + rename + parent-dir + /// fsync. The parent-dir fsync is what actually makes the rename + /// durable on ext4/xfs after a power loss (mirrors the + /// findings.rs H6 discipline). Creates the parent directory if + /// missing and it is non-empty. + pub fn save(&self, path: &Path) -> Result<(), SessionStateError> { + // Only create the parent when it has a non-empty name: + // `Path::new("foo.json").parent()` is `Some("")`, and + // `create_dir_all("")` errors with NotFound on Unix. + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let body = serde_json::to_vec_pretty(self)?; + let tmp = path.with_extension("json.tmp"); + { + let mut f = File::create(&tmp)?; + f.write_all(&body)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path)?; + // Fsync the containing directory so the rename itself is on + // stable storage — without this a power loss right after + // rename() can leave the directory entry pointing at nothing. + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mode::TaskMode; + use crate::plan::PlanStep; + + fn td(name: &str, status: TodoStatus) -> TodoItem { + let mut t = TodoItem::new(name, "investigate"); + t.status = status; + t + } + + #[test] + fn roundtrip_empty() { + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + let s = SessionState::default(); + s.save(&p).unwrap(); + let loaded = SessionState::load(&p).unwrap().unwrap(); + assert_eq!(loaded.version, 1); + assert!(loaded.todo.is_empty()); + assert!(loaded.plan.is_none()); + } + + #[test] + fn load_missing_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("nope.json"); + assert!(SessionState::load(&p).unwrap().is_none()); + } + + #[test] + fn inprogress_todos_flip_to_pending_on_load() { + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + let s = SessionState { + todo: vec![ + td("a", TodoStatus::InProgress), + td("b", TodoStatus::Pending), + td("c", TodoStatus::Done), + ], + deferred: vec![td("d", TodoStatus::InProgress)], + ..Default::default() + }; + s.save(&p).unwrap(); + let loaded = SessionState::load(&p).unwrap().unwrap(); + assert_eq!(loaded.todo[0].status, TodoStatus::Pending); + assert_eq!(loaded.todo[1].status, TodoStatus::Pending); + assert_eq!(loaded.todo[2].status, TodoStatus::Done); + assert_eq!(loaded.deferred[0].status, TodoStatus::Pending); + } + + #[test] + fn inprogress_plan_steps_flip_to_pending_on_load() { + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + let mut plan = Plan::new("prompt", "goal", TaskMode::Analysis); + let mut step = PlanStep::new("s1", "t"); + step.status = PlanStepStatus::InProgress; + plan.steps.push(step); + let s = SessionState { + plan: Some(plan), + ..Default::default() + }; + s.save(&p).unwrap(); + let loaded = SessionState::load(&p).unwrap().unwrap(); + assert_eq!( + loaded.plan.unwrap().steps[0].status, + PlanStepStatus::Pending + ); + } + + #[test] + fn atomic_write_overwrites_prior() { + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + SessionState { + completed_run_count: 1, + ..Default::default() + } + .save(&p) + .unwrap(); + SessionState { + completed_run_count: 7, + ..Default::default() + } + .save(&p) + .unwrap(); + let loaded = SessionState::load(&p).unwrap().unwrap(); + assert_eq!(loaded.completed_run_count, 7); + } + + #[test] + fn version_field_defaults_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + std::fs::write(&p, "{}").unwrap(); + let loaded = SessionState::load(&p).unwrap().unwrap(); + assert_eq!(loaded.version, 1); + } + + #[test] + fn save_removes_tmp_file_on_success() { + // Rename should consume the tmp file; nothing suffixed + // `.json.tmp` should linger next to the canonical path. + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + SessionState::default().save(&p).unwrap(); + let tmp = p.with_extension("json.tmp"); + assert!(!tmp.exists(), "tmp left behind at {}", tmp.display()); + } + + #[test] + fn save_creates_missing_parent_dir() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("a/b/c"); + let p = nested.join("session.json"); + SessionState::default().save(&p).unwrap(); + assert!(p.exists()); + } + + #[test] + fn populated_plan_survives_roundtrip() { + // End-to-end guard: a plan produced by define_plan + stored + // via set_plan must come back out of session.json with the + // same id / title / description / status on every step, and + // every non-InProgress step must keep its status (the + // normalise_inprogress pass only touches InProgress). + let dir = tempfile::tempdir().unwrap(); + let p = SessionState::path_in(dir.path()); + let mut plan = Plan::new( + "review fs/btrfs for memory bugs", + "identify every UAF / leak / double-free in fs/btrfs", + TaskMode::Analysis, + ); + let mut s1 = PlanStep::new("s1", "audit accessors.c"); + s1.description = "walk each btrfs_set_*/btrfs_get_* helper".into(); + s1.status = PlanStepStatus::Done; + s1.todo_ids = vec!["t-accessors".into()]; + plan.steps.push(s1); + let s2 = PlanStep::new("s2", "audit ordered-data.c"); + plan.steps.push(s2); + let s3 = PlanStep::new("s3", "audit free-space-cache.c"); + plan.steps.push(s3); + let state = SessionState { + plan: Some(plan), + last_prompt: Some("review fs/btrfs for memory bugs".into()), + ..Default::default() + }; + state.save(&p).unwrap(); + let loaded = SessionState::load(&p).unwrap().unwrap(); + let lp = loaded.plan.expect("plan round-tripped"); + assert_eq!(lp.steps.len(), 3); + assert_eq!(lp.steps[0].id, "s1"); + assert_eq!(lp.steps[0].status, PlanStepStatus::Done); + assert_eq!(lp.steps[0].todo_ids, vec!["t-accessors".to_string()]); + assert_eq!( + lp.steps[0].description, + "walk each btrfs_set_*/btrfs_get_* helper" + ); + assert_eq!(lp.steps[1].id, "s2"); + assert_eq!(lp.steps[1].status, PlanStepStatus::Pending); + assert_eq!(lp.mode, TaskMode::Analysis); + assert_eq!( + loaded.last_prompt.as_deref(), + Some("review fs/btrfs for memory bugs") + ); + } +} diff --git a/kres-core/src/task.rs b/kres-core/src/task.rs index a369f18..cd95ef8 100644 --- a/kres-core/src/task.rs +++ b/kres-core/src/task.rs @@ -124,6 +124,10 @@ struct Inner { /// Counter against `--turns N`. Incremented ONLY on successful /// task completion (no error AND produced analysis). completed_run_count: u32, + /// Optional plan (produced once per top-level prompt). When set, + /// the session persistence layer saves it alongside the todo + /// list so a resumed session sees the same decomposition. + plan: Option, } struct Caches { @@ -146,6 +150,7 @@ impl TaskManager { todo: Vec::new(), findings: Vec::new(), completed_run_count: 0, + plan: None, }), caches: Mutex::new(Caches { symbol_cache: LruCache::new(symbol_cap), @@ -182,6 +187,7 @@ impl TaskManager { todo: Vec::new(), findings: Vec::new(), completed_run_count: 0, + plan: None, }), caches: Mutex::new(Caches { symbol_cache: LruCache::new(2000), @@ -521,6 +527,69 @@ impl TaskManager { } } + /// Flip every `InProgress` todo back to `Pending`. Called on + /// exit paths that drain the todo list (ctrl-c, --turns cap, + /// goal-met stop) so items are persisted/deferred instead of + /// orphaned in a non-terminal status that no process owns any + /// more. + pub async fn reset_in_progress_to_pending(&self) -> usize { + let mut g = self.inner.write().await; + let mut n = 0usize; + for i in g.todo.iter_mut() { + if i.status == TodoStatus::InProgress { + i.status = TodoStatus::Pending; + n += 1; + } + } + n + } + + // -- plan ---------------------------------------------------------- + + pub async fn plan_snapshot(&self) -> Option { + self.inner.read().await.plan.clone() + } + + /// Install a plan (or clear it when `None`). When the new plan + /// is `Some` and its step ids differ from the prior plan, walks + /// the current todo list and clears `step_id` on any todo whose + /// prior step id is not in the new plan — otherwise those + /// orphans would drag the next `sync_plan_from_todo` pass over + /// the plan's linkage directions and never roll up into any + /// step. When the new plan is `None` (or carries no steps), + /// strips `step_id` from every todo. + pub async fn set_plan(&self, plan: Option) { + let new_step_ids: std::collections::BTreeSet = match plan.as_ref() { + Some(p) => p.steps.iter().map(|s| s.id.clone()).collect(), + None => std::collections::BTreeSet::new(), + }; + let mut g = self.inner.write().await; + g.plan = plan; + for t in g.todo.iter_mut() { + if !t.step_id.is_empty() && !new_step_ids.contains(&t.step_id) { + t.step_id = String::new(); + } + } + } + + /// Recompute plan step statuses from the current todo list. + /// No-op when no plan is set. Call after any todo mutation that + /// could flip a linked item's status. + pub async fn sync_plan_from_todo(&self) { + let mut g = self.inner.write().await; + let todo = g.todo.clone(); + if let Some(plan) = g.plan.as_mut() { + plan.sync_from_todo(&todo); + } + } + + /// Overwrite `completed_run_count`. Only used by the session + /// loader to restore a persisted count on resume — the normal + /// path is the `finish_ok` auto-increment. + pub async fn set_completed_run_count(&self, n: u32) { + self.inner.write().await.completed_run_count = n; + } + // -- findings ------------------------------------------------------ pub async fn findings_snapshot(&self) -> Vec { @@ -679,6 +748,99 @@ impl LruCache { mod tests { use super::*; + #[tokio::test] + async fn reset_in_progress_flips_only_inprogress() { + let mgr = TaskManager::new(); + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::InProgress; + let mut b = TodoItem::new("b", "investigate"); + b.status = TodoStatus::Pending; + let mut c = TodoItem::new("c", "investigate"); + c.status = TodoStatus::Done; + let mut d = TodoItem::new("d", "investigate"); + d.status = TodoStatus::InProgress; + mgr.replace_todo(vec![a, b, c, d]).await; + let flipped = mgr.reset_in_progress_to_pending().await; + assert_eq!(flipped, 2); + let snap = mgr.todo_snapshot().await; + assert_eq!(snap[0].status, TodoStatus::Pending); + assert_eq!(snap[1].status, TodoStatus::Pending); + assert_eq!(snap[2].status, TodoStatus::Done); + assert_eq!(snap[3].status, TodoStatus::Pending); + } + + #[tokio::test] + async fn set_and_sync_plan_marks_step_done_when_todos_terminal() { + use crate::plan::{Plan, PlanStep, PlanStepStatus}; + let mgr = TaskManager::new(); + let mut plan = Plan::new("p", "g", crate::TaskMode::Analysis); + let mut step = PlanStep::new("s1", "t"); + step.todo_ids = vec!["a".into(), "b".into()]; + plan.steps.push(step); + mgr.set_plan(Some(plan)).await; + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::Done; + let mut b = TodoItem::new("b", "investigate"); + b.status = TodoStatus::Skipped; + mgr.replace_todo(vec![a, b]).await; + mgr.sync_plan_from_todo().await; + let out = mgr.plan_snapshot().await.unwrap(); + assert_eq!(out.steps[0].status, PlanStepStatus::Done); + } + + #[tokio::test] + async fn set_completed_run_count_overrides_counter() { + let mgr = TaskManager::new(); + mgr.set_completed_run_count(42).await; + assert_eq!(mgr.completed_run_count().await, 42); + } + + #[tokio::test] + async fn set_plan_strips_orphan_step_ids_from_todos() { + // When the slow or todo agent rewrites the plan and drops + // a step id the new plan no longer owns, existing todos + // pointing at the dead id must be cleared so they are not + // stranded. The todo's step_id goes back to empty and the + // todo-agent's next turn re-links it against the new plan. + use crate::plan::{Plan, PlanStep}; + let mgr = TaskManager::new(); + let mut old_plan = Plan::new("p", "g", crate::TaskMode::Analysis); + old_plan.steps.push(PlanStep::new("s1", "old-one")); + old_plan.steps.push(PlanStep::new("s2", "old-two")); + mgr.set_plan(Some(old_plan)).await; + let mut a = TodoItem::new("a", "investigate"); + a.step_id = "s1".into(); + let mut b = TodoItem::new("b", "investigate"); + b.step_id = "s2".into(); + let c = TodoItem::new("c", "investigate"); // empty step_id + mgr.replace_todo(vec![a, b, c]).await; + + // New plan drops s2, keeps s1, adds s3. + let mut new_plan = Plan::new("p", "g", crate::TaskMode::Analysis); + new_plan.steps.push(PlanStep::new("s1", "new-one")); + new_plan.steps.push(PlanStep::new("s3", "new-three")); + mgr.set_plan(Some(new_plan)).await; + + let snap = mgr.todo_snapshot().await; + assert_eq!(snap[0].step_id, "s1"); // still valid, preserved + assert_eq!(snap[1].step_id, ""); // s2 dead, cleared + assert_eq!(snap[2].step_id, ""); // was empty, unchanged + } + + #[tokio::test] + async fn set_plan_none_clears_every_step_id() { + use crate::plan::{Plan, PlanStep}; + let mgr = TaskManager::new(); + let mut plan = Plan::new("p", "g", crate::TaskMode::Analysis); + plan.steps.push(PlanStep::new("s1", "x")); + mgr.set_plan(Some(plan)).await; + let mut a = TodoItem::new("a", "investigate"); + a.step_id = "s1".into(); + mgr.replace_todo(vec![a]).await; + mgr.set_plan(None).await; + assert_eq!(mgr.todo_snapshot().await[0].step_id, ""); + } + #[tokio::test] async fn spawn_and_reap_ok() { let mgr = TaskManager::new(); diff --git a/kres-core/src/todo.rs b/kres-core/src/todo.rs index 91e97e4..357152a 100644 --- a/kres-core/src/todo.rs +++ b/kres-core/src/todo.rs @@ -44,6 +44,15 @@ pub struct TodoItem { /// references that cite the old id. #[serde(default, skip_serializing_if = "String::is_empty")] pub id: String, + /// Optional pointer to the plan step this todo is executing. + /// Written by the todo-agent when a plan is in play (the agent + /// sees the plan in its user JSON and picks the best-matching + /// step id); consumed by `crate::plan::Plan::sync_from_todo` to + /// roll up step status. Empty string means "not yet linked" — + /// most common for todos created before a plan existed, or for + /// followups the agent couldn't confidently attribute. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub step_id: String, } fn default_pending() -> TodoStatus { @@ -60,6 +69,7 @@ impl TodoItem { depends_on: Vec::new(), coverage: String::new(), id: String::new(), + step_id: String::new(), } } } diff --git a/kres-repl/src/commands.rs b/kres-repl/src/commands.rs index cf8fc5d..0ea9ce7 100644 --- a/kres-repl/src/commands.rs +++ b/kres-repl/src/commands.rs @@ -26,6 +26,10 @@ pub enum Command { Cost, /// `/todo [--clear]` — show or clear the current todo list. Todo { clear: bool }, + /// `/plan` — show the current plan (step id, status, title) + /// if one was produced by `define_plan` when the prompt was + /// submitted. Prints a reminder when no plan exists. + Plan, /// `/followup` — list items deferred by goal-met or --turns cap. Followup, /// `/summary [filename]` — render the run's report.md + @@ -100,6 +104,7 @@ pub fn parse_command(line: &str) -> Command { "todo" => Command::Todo { clear: rest.split_whitespace().any(|tok| tok == "--clear"), }, + "plan" => Command::Plan, "followup" | "followups" | "deferred" => Command::Followup, "summary" => Command::Summary { filename: rest.split_whitespace().next().map(|s| s.to_string()), @@ -217,6 +222,11 @@ mod tests { ); } + #[test] + fn parses_plan() { + assert_eq!(parse_command("/plan"), Command::Plan); + } + #[test] fn parses_followup_and_deferred() { assert_eq!(parse_command("/followup"), Command::Followup); diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index c889106..b4ed9a7 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -64,6 +64,12 @@ pub struct ReplConfig { /// expected "write hello-world.c" to land beside their cwd). /// Defaults to `.`; overridden by `--workspace` in main.rs. pub workspace: PathBuf, + /// Path to `/session.json`. When set, the reaper and + /// drain paths persist a [`kres_core::SessionState`] snapshot + /// here on every mutation so an interrupted session can be + /// resumed by re-invoking kres with the same `--results DIR`. + /// None disables persistence (no-op writes). + pub persist_path: Option, } impl Default for ReplConfig { @@ -78,6 +84,7 @@ impl Default for ReplConfig { template_path: None, stdio: false, workspace: PathBuf::from("."), + persist_path: None, } } } @@ -206,6 +213,15 @@ pub struct Session { /// long inference, the prompt text moves here so the next /// `/continue` can re-submit it verbatim. interrupted_prompt: Arc>>, + /// Most recent prompt text (captured at the top of + /// `submit_prompt`). Persisted into `/session.json` so + /// a resumed session's `--resume` reporting can show what the + /// operator was working on. + last_prompt: Arc>>, + /// Hash of the last successfully-persisted session state bytes. + /// Lets the reaper tick skip no-op fsyncs when nothing changed. + /// Zero means "never persisted" and always triggers a write. + persist_sig: Arc, /// Set to true by the reaper when the --turns cap is reached. /// The main REPL loop checks this after root_shutdown breaks the /// select; when true, /summary is invoked before teardown so the @@ -246,6 +262,81 @@ pub struct Session { mcp_shutdown: Arc>>>>, } +/// Build a [`kres_core::SessionState`] from live manager + deferred +/// state and persist it atomically to `path`. No-op on write errors +/// (logged at warn level) — a persist failure should never crash a +/// running pipeline. Shared between [`Session::persist_state`] and +/// the reaper loop (which only has clones of the needed Arcs). +/// +/// `last_sig` throttles no-op writes: the reaper loop hands in an +/// `AtomicU64` that holds the hash of the most recently persisted +/// bytes. When the new bytes hash to the same value we skip the +/// fsync'd rename entirely, so an idle session does not pound the +/// disk at 4 writes/sec. Pass a fresh (zeroed) slot to force a +/// write — the hash of valid JSON is never 0. +async fn persist_session_state_to( + path: &Path, + mgr: &Arc, + deferred: &tokio::sync::Mutex>, + last_prompt: Option, + last_sig: Option<&std::sync::atomic::AtomicU64>, +) { + use std::hash::{Hash, Hasher}; + // Snapshot the plan BEFORE syncing so we can diff step + // statuses afterwards and log every transition (pending → + // done etc.). Cheap clone — the plan is usually a handful of + // steps — and only runs inside the reaper tick. + let plan_before = mgr.plan_snapshot().await; + // Keep the plan in sync with the current todo statuses before + // snapshotting, so the persisted plan reflects what has actually + // completed rather than whatever the planner last wrote. + mgr.sync_plan_from_todo().await; + let plan_after = mgr.plan_snapshot().await; + log_plan_status_transitions(plan_before.as_ref(), plan_after.as_ref()); + let state = kres_core::SessionState { + version: 1, + last_prompt, + plan: plan_after, + todo: mgr.todo_snapshot().await, + deferred: deferred.lock().await.clone(), + completed_run_count: mgr.completed_run_count().await, + }; + // Serialise once; hash the bytes for the change-detect latch AND + // (on change) hand the same bytes to save() so we don't pay the + // cost twice. save() does its own serialisation for now; cheap + // enough that the duplication is not worth a wider API change. + let bytes = match serde_json::to_vec(&state) { + Ok(b) => b, + Err(e) => { + tracing::warn!( + target: "kres_repl", + "persist session state to {}: serialise: {e}", + path.display() + ); + return; + } + }; + if let Some(slot) = last_sig { + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + let sig = h.finish(); + // Seq-cst on write + load: the reaper is the sole writer of + // this slot, so Relaxed would suffice; Relaxed it is. + let prior = slot.load(std::sync::atomic::Ordering::Relaxed); + if sig == prior && prior != 0 { + return; + } + slot.store(sig, std::sync::atomic::Ordering::Relaxed); + } + if let Err(e) = state.save(path) { + tracing::warn!( + target: "kres_repl", + "persist session state to {}: {e}", + path.display() + ); + } +} + /// One row of the accumulated-findings ledger — matches 's /// `_accumulated_findings.append({"task": ..., "analysis": ...})` #[derive(Debug, Clone)] @@ -330,6 +421,8 @@ impl Session { accumulated: Arc::new(tokio::sync::Mutex::new(Vec::new())), deferred: Arc::new(tokio::sync::Mutex::new(Vec::new())), interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), + last_prompt: Arc::new(tokio::sync::Mutex::new(None)), + persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -360,6 +453,8 @@ impl Session { accumulated: Arc::new(tokio::sync::Mutex::new(Vec::new())), deferred: Arc::new(tokio::sync::Mutex::new(Vec::new())), interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), + last_prompt: Arc::new(tokio::sync::Mutex::new(None)), + persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -421,6 +516,58 @@ impl Session { self.deferred.lock().await.clone() } + /// Persist session state (plan + todo + deferred + counters) to + /// `cfg.persist_path`. No-op when the config didn't set one. + /// Called from the reaper tick and the various drain paths so + /// an interrupted session can be resumed via + /// `kres --results DIR` on the next invocation. + pub async fn persist_state(&self) { + let Some(path) = self.cfg.persist_path.as_ref() else { + return; + }; + let last_prompt = self.last_prompt.lock().await.clone(); + persist_session_state_to( + path, + &self.mgr, + &self.deferred, + last_prompt, + Some(&self.persist_sig), + ) + .await; + } + + /// Load a prior session from `cfg.persist_path` and seed the + /// manager + deferred list. Called once at REPL startup. Returns + /// `Ok(Some(state))` on a successful resume, `Ok(None)` when + /// there's nothing to resume (no persist_path or file absent), + /// and `Err` on parse / I/O failure. + pub async fn resume_state(&self) -> Result> { + let Some(path) = self.cfg.persist_path.as_ref() else { + return Ok(None); + }; + let state = match kres_core::SessionState::load(path) { + Ok(Some(s)) => s, + Ok(None) => return Ok(None), + Err(e) => return Err(anyhow::anyhow!("load {}: {e}", path.display())), + }; + // Seed manager state. `SessionState::load` already flipped + // InProgress → Pending, so re-seeded items come back ready + // for /continue or auto-continue to pick them up. + self.mgr.replace_todo(state.todo.clone()).await; + self.mgr.set_plan(state.plan.clone()).await; + self.mgr + .set_completed_run_count(state.completed_run_count) + .await; + { + let mut def = self.deferred.lock().await; + *def = state.deferred.clone(); + } + if let Some(p) = state.last_prompt.clone() { + *self.last_prompt.lock().await = Some(p); + } + Ok(Some(state)) + } + pub fn with_prompt_file(mut self, pf: kres_agents::PromptFile) -> Self { self.lenses = pf.lenses; if !pf.prompt.is_empty() { @@ -553,6 +700,10 @@ impl Session { let root = self.mgr.root_shutdown().clone(); let mgr_for_ctrlc = self.mgr.clone(); + let persist_for_ctrlc = self.cfg.persist_path.clone(); + let deferred_for_ctrlc = self.deferred.clone(); + let last_prompt_for_ctrlc = self.last_prompt.clone(); + let persist_sig_for_ctrlc = self.persist_sig.clone(); let ctrlc_handle = tokio::spawn(async move { // Each round: wait for ctrl-c, cooperatively cancel, arm a // 3s second-hit window for a hard exit, then loop. The @@ -572,15 +723,20 @@ impl Session { // the next /continue. Without this a tasks-were- // running ctrl-c would strand those todos in // "in_progress" forever. - { - let items = mgr_for_ctrlc.todo_snapshot().await; - for item in items { - if item.status == kres_core::TodoStatus::InProgress { - mgr_for_ctrlc - .mark_todo_status(&item.name, kres_core::TodoStatus::Pending) - .await; - } - } + mgr_for_ctrlc.reset_in_progress_to_pending().await; + // Snapshot to disk so a subsequent `kres --results + // DIR` invocation can resume from where the operator + // pressed ctrl-c. + if let Some(ref p) = persist_for_ctrlc { + let lp = last_prompt_for_ctrlc.lock().await.clone(); + persist_session_state_to( + p, + &mgr_for_ctrlc, + &deferred_for_ctrlc, + lp, + Some(&persist_sig_for_ctrlc), + ) + .await; } root.cancel(); tokio::select! { @@ -607,6 +763,9 @@ impl Session { let task_prompts_for_reaper = self.task_prompts.clone(); let accumulated_for_reaper = self.accumulated.clone(); let deferred_for_reaper = self.deferred.clone(); + let persist_path_for_reaper = self.cfg.persist_path.clone(); + let last_prompt_for_reaper = self.last_prompt.clone(); + let persist_sig_for_reaper = self.persist_sig.clone(); let merger_for_reaper = self.consolidator.clone(); let store_for_reaper = self.findings_store.clone(); let interrupted_for_reaper = self.interrupted_prompt.clone(); @@ -964,6 +1123,7 @@ impl Session { .count(), followups.len(), ); + let plan_for_todo = mgr_for_reaper.plan_snapshot().await; match kres_agents::update_todo_via_agent_with_logger( tc, &completed_query, @@ -971,6 +1131,7 @@ impl Session { &followups, ¤t, &lenses_for_reaper, + plan_for_todo.as_ref(), logger_for_reaper.clone(), ) .await @@ -978,17 +1139,35 @@ impl Session { Ok(updated) => { kres_core::async_eprintln!( "[todo update] after: {} item(s) ({} pending, {} done)", - updated.len(), + updated.todo.len(), updated + .todo .iter() .filter(|t| t.status == kres_core::TodoStatus::Pending) .count(), updated + .todo .iter() .filter(|t| t.status == kres_core::TodoStatus::Done) .count(), ); - mgr_for_reaper.replace_todo(updated).await; + // When the todo agent rewrote the + // plan, swap it in BEFORE replacing + // the todo list so the next + // sync_plan_from_todo tick sees the + // new plan matching the new step_ids + // the same turn emitted. + if let Some(rewrite) = updated.plan { + let prior = mgr_for_reaper.plan_snapshot().await; + let new_plan = rewrite.apply_to(prior.as_ref()); + log_plan_change( + "todo agent: plan rewrite", + prior.as_ref(), + &new_plan, + ); + mgr_for_reaper.set_plan(Some(new_plan)).await; + } + mgr_for_reaper.replace_todo(updated.todo).await; } Err(e) => { tracing::warn!( @@ -1031,8 +1210,15 @@ impl Session { } combined.push_str(&format!("## {}\n\n{}", e.task, e.analysis)); } - let check = - kres_agents::check_goal(&gc, &per_task_prompt, &goal, &combined).await; + let plan_for_check = mgr_for_reaper.plan_snapshot().await; + let check = kres_agents::check_goal( + &gc, + &per_task_prompt, + &goal, + &combined, + plan_for_check.as_ref(), + ) + .await; kres_core::async_eprintln!( "[goal check] met={} reason={}", check.met, @@ -1043,6 +1229,12 @@ impl Session { "[goal met: {}]", truncate(&check.reason, 200) ); + // Any lingering InProgress items belong + // to tasks the reaper already handled; + // flip them to Pending so they join the + // deferred drain below instead of being + // silently dropped. + mgr_for_reaper.reset_in_progress_to_pending().await; // Drain pending todos into the deferred // ledger so /followup can list them. let remaining = mgr_for_reaper.todo_snapshot().await; @@ -1102,6 +1294,8 @@ impl Session { "[goal-not-met → todo update] injecting {} missing item(s) as question followups", missing_fus.len() ); + let plan_for_todo = + mgr_for_reaper.plan_snapshot().await; match kres_agents::update_todo_via_agent_with_logger( tc, &completed_query, @@ -1109,6 +1303,7 @@ impl Session { &missing_fus, ¤t, &lenses_for_reaper, + plan_for_todo.as_ref(), logger_for_reaper.clone(), ) .await @@ -1116,11 +1311,25 @@ impl Session { Ok(updated) => { kres_core::async_eprintln!( "[goal-not-met → todo update] after: {} item(s) ({} pending, {} done)", - updated.len(), - updated.iter().filter(|t| t.status == kres_core::TodoStatus::Pending).count(), - updated.iter().filter(|t| t.status == kres_core::TodoStatus::Done).count(), + updated.todo.len(), + updated.todo.iter().filter(|t| t.status == kres_core::TodoStatus::Pending).count(), + updated.todo.iter().filter(|t| t.status == kres_core::TodoStatus::Done).count(), ); - mgr_for_reaper.replace_todo(updated).await; + if let Some(rewrite) = updated.plan { + let prior = + mgr_for_reaper.plan_snapshot().await; + let new_plan = + rewrite.apply_to(prior.as_ref()); + log_plan_change( + "todo agent: plan rewrite (goal-not-met)", + prior.as_ref(), + &new_plan, + ); + mgr_for_reaper + .set_plan(Some(new_plan)) + .await; + } + mgr_for_reaper.replace_todo(updated.todo).await; } Err(e) => { tracing::warn!( @@ -1143,6 +1352,12 @@ impl Session { kres_core::async_eprintln!( "\n=== --turns {turns_limit} reached — {done} task run(s) completed ===" ); + // Flip any in-flight items to Pending so the + // drain below carries them to the deferred + // list too — otherwise a task that happened + // to be mid-run when the cap hit would be + // lost from both the todo list and /followup. + mgr_for_reaper.reset_in_progress_to_pending().await; // §32: move every pending/blocked todo item // to the deferred list so /followup can list // them, then clear the todo list. Matches @@ -1261,6 +1476,11 @@ impl Session { kres_core::async_eprintln!( "\n=== --turns 0: {reason} — REPL staying open; submit another prompt, /summary, or /quit ===" ); + // Flip InProgress → Pending before the drain + // so the deferred list is complete; an item + // mid-run at goal-met time shouldn't silently + // disappear. + mgr_for_reaper.reset_in_progress_to_pending().await; // Move any leftover pending/blocked items to // /followup's deferred list and clear the // active queue so auto-continue doesn't @@ -1292,6 +1512,24 @@ impl Session { turns0_stop_announced = true; } } + // Persist session state at the end of every reaper + // tick. This captures all mutation paths (reaped + // tasks, followup drains, goal-met / --turns drains) + // in a single spot rather than sprinkling save calls + // across every callsite. The content-hash latch in + // persist_session_state_to makes idle ticks a no-op + // so the 250ms cadence does not pound the disk. + if let Some(ref p) = persist_path_for_reaper { + let lp = last_prompt_for_reaper.lock().await.clone(); + persist_session_state_to( + p, + &mgr_for_reaper, + &deferred_for_reaper, + lp, + Some(&persist_sig_for_reaper), + ) + .await; + } } }); @@ -1384,6 +1622,7 @@ impl Session { self.print_todo().await; } } + Command::Plan => self.cmd_plan().await, Command::Followup => self.cmd_followup().await, Command::Summary { filename } => self.cmd_summary(filename, false).await, Command::SummaryMarkdown { filename } => { @@ -1629,6 +1868,8 @@ impl Session { // enough state for /continue to re-submit. Cleared after // spawn — the spawned task owns re-execution from here. *self.interrupted_prompt.lock().await = Some(text.clone()); + // Track the latest prompt for session.json persistence. + *self.last_prompt.lock().await = Some(text.clone()); // Ask the main agent for a concrete completion goal // ( / §4). Failures fall through to a null @@ -1663,6 +1904,26 @@ impl Session { self.any_coding_task .store(true, std::sync::atomic::Ordering::Release); } + // Ask the goal agent for a plan decomposition, but only on + // operator-typed submissions — pipeline-driven follow-ups + // already live under the original plan and should not spawn + // fresh ones. Gated on a goal having been produced: without + // a goal the planner has nothing to work from. Pass the + // manager's current plan so the planner can decide whether + // this prompt is a continuation (preserve ids) or a fresh + // topic (emit a new plan); set_plan reconciles orphan + // step_ids on todos when ids change. + if include_recent_context { + if let (Some(gc), Some(goal)) = (&self.goal_client, defined_goal.as_ref()) { + let existing = self.mgr.plan_snapshot().await; + if let Some(plan) = + kres_agents::define_plan(gc, &text, goal, task_mode, existing.as_ref()).await + { + log_plan_change("define_plan", existing.as_ref(), &plan); + self.mgr.set_plan(Some(plan)).await; + } + } + } let orc_task = orc.clone(); // Snapshot findings BEFORE spawning so the task's // RunContext sees the running list. bugs.md#H1: the read is @@ -1697,6 +1958,21 @@ impl Session { } else { text }; + // Snapshot the plan BEFORE spawning so the task's RunContext + // sees the plan that was current when the task was + // submitted. A later operator prompt may replace the plan + // (set_plan(Some(new))) while this task is still mid-run; + // the cloned snapshot keeps each task pinned to its own + // plan for the duration. + let plan_for_ctx = self.mgr.plan_snapshot().await; + // Only the initial task spawned from an operator-typed + // prompt gets to rewrite the plan via the slow agent. A + // pipeline follow-up (/next, /continue, auto-continue) has + // include_recent_context=false and keeps this flag off so + // later-turn slow calls can't reshape the plan mid-sweep; + // incremental plan edits flow through the todo agent for + // those. + let allow_plan_rewrite = include_recent_context; let task_id = self .mgr .spawn(task_brief, None, move |handle| async move { @@ -1705,6 +1981,8 @@ impl Session { task_brief: task_brief_clone, original_prompt, mode: task_mode, + plan: plan_for_ctx, + allow_plan_rewrite, }; // Dispatch by mode: // Coding → single slow call with slow_coding_system; @@ -1746,6 +2024,31 @@ impl Session { }; match res { Ok(summary) => { + // Slow-agent plan rewrite: when the first + // slow call came back with a rewritten plan + // (ctx.allow_plan_rewrite=true and the agent + // decided to), apply it BEFORE returning the + // TaskOutcome so the reaper-tick persist and + // the post-task todo-agent update both see + // the new plan. + if let Some(rewrite) = summary.plan { + if let Some(mgr) = handle.manager() { + let prior = mgr.plan_snapshot().await; + // Merge rewrite's steps with the + // prior plan's metadata so a + // forgotten prompt / goal / mode / + // created_at in the LLM reply + // cannot silently clobber + // identifying fields. + let new_plan = rewrite.apply_to(prior.as_ref()); + log_plan_change( + "slow: plan rewrite", + prior.as_ref(), + &new_plan, + ); + mgr.set_plan(Some(new_plan)).await; + } + } // findings-N.json is written by the reaper // with the CUMULATIVE merged list (see the // `findings_store` write site in run()). The @@ -2128,6 +2431,83 @@ impl Session { } } + /// `/plan` — show the current plan, produced by `define_plan` + /// when the operator's last top-level prompt was submitted. + /// Prints each step with its id + live status; the status + /// reflects `sync_plan_from_todo`, which the reaper tick runs + /// before every persist. When no plan exists (goal agent not + /// configured, or the planner call failed) prints a hint. + async fn cmd_plan(&self) { + // Sync once so the status we print matches the linked todo + // statuses right now, not whatever the planner last wrote. + self.mgr.sync_plan_from_todo().await; + let Some(plan) = self.mgr.plan_snapshot().await else { + println!( + "(no plan — either no goal agent configured or define_plan failed on the last prompt)" + ); + return; + }; + // Pull the current todo list so we can render links in BOTH + // directions (step.todo_ids → todos, and todos with + // matching step_id → step). sync_plan_from_todo above only + // rolls up status; it does not backfill step.todo_ids, so + // the step-side list is often empty while todos actually + // point at the step via their own step_id field. + let todo = self.mgr.todo_snapshot().await; + println!( + "plan — mode={}, {} step(s)", + plan.mode.as_str(), + plan.steps.len() + ); + println!("goal: {}", truncate(&plan.goal, 120)); + for s in &plan.steps { + let status = match s.status { + kres_core::PlanStepStatus::Pending => "pending", + kres_core::PlanStepStatus::InProgress => "in-progress", + kres_core::PlanStepStatus::Done => "done", + kres_core::PlanStepStatus::Skipped => "skipped", + }; + println!(" [{}] {:<11} {}", s.id, status, truncate(&s.title, 80)); + if !s.description.is_empty() { + println!(" — {}", truncate(&s.description, 120)); + } + // Union of step.todo_ids (down-link) and todos whose + // step_id matches s.id (up-link). Dedup by the todo's + // `id` when set, else by `name`. Skip when nothing + // links either way. + let mut linked: Vec<&kres_core::TodoItem> = Vec::new(); + for tid in &s.todo_ids { + if let Some(t) = todo.iter().find(|i| { + (!i.id.is_empty() && i.id == *tid) || i.name == *tid + }) { + if !linked.iter().any(|lt| std::ptr::eq(*lt, t)) { + linked.push(t); + } + } + } + for t in &todo { + if !t.step_id.is_empty() && t.step_id == s.id + && !linked.iter().any(|lt| std::ptr::eq(*lt, t)) + { + linked.push(t); + } + } + if !linked.is_empty() { + let labels: Vec = linked + .iter() + .map(|t| { + if !t.id.is_empty() { + t.id.clone() + } else { + t.name.clone() + } + }) + .collect(); + println!(" linked: {}", labels.join(", ")); + } + } + } + /// `/followup` — list items deferred by a goal-met or --turns /// cap. Matches command. async fn cmd_followup(&self) { @@ -3254,6 +3634,7 @@ fn print_help() { println!(" /compact summarise accumulated context into one short entry"); println!(" /cost show API token usage"); println!(" /todo show the todo list"); + println!(" /plan show the current plan (produced by define_plan)"); println!(" /report write findings report (markdown)"); println!(" /load submit a file's contents as the next prompt"); println!(" /edit open $EDITOR on a scratch file, submit on save"); @@ -3283,6 +3664,108 @@ fn truncate(s: &str, n: usize) -> String { format!("{head}…") } +/// Log a plan replacement to the REPL, with a change summary +/// against the prior plan (if any). `source` names the writer +/// ("define_plan" / "slow: plan rewrite" / "todo agent: plan +/// rewrite") so the operator can see which agent reshaped it. +/// +/// Emits one top-line summary plus, when the prior plan existed, +/// per-step lines for steps that were added, removed, or whose +/// title changed. For a fresh plan (no prior) falls back to the +/// same "title per step" dump the session used before this helper +/// existed. +pub(crate) fn log_plan_change( + source: &str, + prior: Option<&kres_core::Plan>, + new: &kres_core::Plan, +) { + let prior_count = prior.map(|p| p.steps.len()).unwrap_or(0); + kres_core::async_eprintln!( + "[{source}] {} step(s){}", + new.steps.len(), + match prior { + Some(_) => format!(" (was {prior_count})"), + None => String::new(), + } + ); + let Some(prior) = prior else { + // No prior → list every step inline so the operator sees + // the initial decomposition without needing /plan. + for s in &new.steps { + kres_core::async_eprintln!(" [{}] {}", s.id, truncate(&s.title, 100)); + } + return; + }; + let prior_by_id: std::collections::BTreeMap<&str, &kres_core::PlanStep> = + prior.steps.iter().map(|s| (s.id.as_str(), s)).collect(); + let new_by_id: std::collections::BTreeMap<&str, &kres_core::PlanStep> = + new.steps.iter().map(|s| (s.id.as_str(), s)).collect(); + // Added: in new but not in prior. + for s in &new.steps { + if !prior_by_id.contains_key(s.id.as_str()) { + kres_core::async_eprintln!(" + [{}] {}", s.id, truncate(&s.title, 100)); + } + } + // Removed: in prior but not in new. + for s in &prior.steps { + if !new_by_id.contains_key(s.id.as_str()) { + kres_core::async_eprintln!(" - [{}] {}", s.id, truncate(&s.title, 100)); + } + } + // Retitled: id preserved, title changed. + for s in &new.steps { + if let Some(old) = prior_by_id.get(s.id.as_str()) { + if old.title != s.title { + kres_core::async_eprintln!( + " ~ [{}] {} → {}", + s.id, + truncate(&old.title, 60), + truncate(&s.title, 60) + ); + } + } + } + // Fully unchanged (same id, same title, possibly status drift + // which we report separately in sync_plan_from_todo). Counted + // silently — listing them would bury the signal. +} + +/// Log plan-step status transitions caused by `sync_plan_from_todo`. +/// `prior` + `after` come from two plan_snapshot calls bracketing +/// the sync. Emits one line per step whose status changed (e.g. +/// `[plan] s3 pending → done`). +pub(crate) fn log_plan_status_transitions( + prior: Option<&kres_core::Plan>, + after: Option<&kres_core::Plan>, +) { + let (Some(prior), Some(after)) = (prior, after) else { + return; + }; + let prior_by_id: std::collections::BTreeMap<&str, kres_core::PlanStepStatus> = + prior.steps.iter().map(|s| (s.id.as_str(), s.status)).collect(); + for s in &after.steps { + if let Some(prior_status) = prior_by_id.get(s.id.as_str()) { + if *prior_status != s.status { + kres_core::async_eprintln!( + "[plan] {} {} → {}", + s.id, + plan_status_label(*prior_status), + plan_status_label(s.status), + ); + } + } + } +} + +fn plan_status_label(s: kres_core::PlanStepStatus) -> &'static str { + match s { + kres_core::PlanStepStatus::Pending => "pending", + kres_core::PlanStepStatus::InProgress => "in-progress", + kres_core::PlanStepStatus::Done => "done", + kres_core::PlanStepStatus::Skipped => "skipped", + } +} + /// Sorted signature tuple per finding — used to detect merge /// quiescence (§16). Matches ///id, status, summary, reproducer_sketch, diff --git a/kres/src/main.rs b/kres/src/main.rs index 5086ccc..2b506d3 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -652,6 +652,12 @@ async fn run_repl(args: ReplArgs) -> Result<()> { let _ = (&report_path, &todo_path); let mgr = TaskManager::new(); + // session.json lives beside findings.json / report.md so an + // interrupted run can be resumed via `--results `. + // Always set — even for defaulted session dirs, so crash recovery + // works out-of-the-box; operators who don't point at the dir + // again will simply never read it. + let persist_path = Some(results_dir.join("session.json")); let cfg = ReplConfig { stop_grace: std::time::Duration::from_millis(args.stop_grace_ms), findings_base, @@ -665,8 +671,30 @@ async fn run_repl(args: ReplArgs) -> Result<()> { template_path: args.template.clone(), stdio: args.stdio, workspace: args.workspace.clone(), + persist_path, }; let mut session = Session::new(mgr, cfg); + // Resume from a prior session.json when the operator pointed at + // an existing results dir. `resume_state` is a no-op when the + // file isn't there, so a fresh session starts empty. + match session.resume_state().await { + Ok(Some(state)) => { + kres_core::async_eprintln!( + "resume: {} todo item(s), {} deferred, turns done={}", + state.todo.len(), + state.deferred.len(), + state.completed_run_count + ); + if let Some(ref prompt) = state.last_prompt { + let short: String = prompt.chars().take(80).collect(); + kres_core::async_eprintln!("resume: last prompt: {}", short); + } + } + Ok(None) => {} + Err(e) => { + kres_core::async_eprintln!("resume: {e}"); + } + } // Turn logger: always on (see todo.md §2). Rooted at cwd so // `.kres/logs//` lands next to the session artifacts. From 0282e19dd428bbd5b4a811d97356ab3efb2605c2 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 10:11:55 -0700 Subject: [PATCH 26/76] kres: require --resume to load a prior session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session persistence shipped as "point at an existing --results dir and kres picks up where it left off". That default bled prior sessions' plans, todos, and counters into a new run whenever the operator re-used a results dir. Gate resume behind an explicit opt-in: - `--resume` CLI flag. Without it, kres starts clean even when `--results` points at a directory with a `session.json`. When `session.json` is missing but `session.json.prev` exists (from the backup path below), `--resume` falls back to the backup and logs the swap. - On startup without `--resume`, any existing `session.json` in the results dir is renamed to `session.json.prev` BEFORE the reaper starts writing — the prior snapshot survives the first 250 ms reaper tick that would otherwise overwrite it. - `/resume [PATH]` REPL command. With PATH: load that file. Without: prefer `/session.json.prev`, else the live `session.json`. Calls the same `resume_state_from` helper `--resume` uses, so the `InProgress → Pending` normalisation and the deferred-list seeding are identical. Operators now see an explicit choice at startup ("note: prior session snapshot moved to DIR/session.json.prev; starting clean") and can recover mid-session with `/resume` when they realise they wanted the prior state after all. Signed-off-by: Chris Mason --- CLAUDE.md | 12 +++-- kres-repl/src/commands.rs | 33 +++++++++++++ kres-repl/src/session.rs | 95 ++++++++++++++++++++++++++++++++++--- kres/src/main.rs | 99 +++++++++++++++++++++++++++++++++------ 4 files changed, 214 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8828e2e..9851d1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,10 +65,13 @@ User prompt → Task created → Task thread starts `completed_run_count` + last prompt. Written atomically (tmp + fsync + rename) from the reaper tick and the various drain paths. -- Resume: `kres --results ` on an existing dir loads the - snapshot, flips every `InProgress` todo/plan step back to - `Pending` (its prior executor is gone), and seeds the manager + - deferred list before the REPL starts. +- Resume: `kres --results --resume` loads the snapshot, + flips every `InProgress` todo/plan step back to `Pending` (its + prior executor is gone), and seeds the manager + deferred list + before the REPL starts. Without `--resume`, any existing + session.json is left untouched on disk and the REPL starts + clean; a note in the startup banner points at the file so the + operator knows the state is recoverable. - InProgress drains: ctrl-c, the `--turns N` cap, goal-met, and `--turns 0` follow-stagnation all call `TaskManager::reset_in_progress_to_pending()` before moving items @@ -111,6 +114,7 @@ Rate limiters are shared across agents that use the same API key string. | `/tasks` `/task` | Show active tasks and states | | `/todo` | Show pending items (ready/blocked) + completed count | | `/plan` | Show the current plan + per-step status (produced by `define_plan`) | +| `/resume [PATH]` | Load a persisted `session.json` (defaults to `/session.json.prev` → live file). Overwrites in-memory state | | `/todo --clear` | Clear all todo items | | `/cost` | Token usage by agent role and model | | `/summary [FILE]` | Fast agent renders the run's report.md + findings.json into a bug report via the embedded `summary` slash-command template. Output defaults to `bug-report.txt` in the results dir | diff --git a/kres-repl/src/commands.rs b/kres-repl/src/commands.rs index 0ea9ce7..8fc4576 100644 --- a/kres-repl/src/commands.rs +++ b/kres-repl/src/commands.rs @@ -30,6 +30,14 @@ pub enum Command { /// if one was produced by `define_plan` when the prompt was /// submitted. Prints a reminder when no plan exists. Plan, + /// `/resume [PATH]` — load plan + todo + deferred + turn + /// counter from a persisted `session.json`. When PATH is + /// omitted, reads `/session.json.prev` if present + /// (the backup kres writes at startup when you did not pass + /// `--resume`) then falls back to `/session.json`. + /// Overwrites the current in-memory state, so run it before + /// submitting prompts. + Resume { path: Option }, /// `/followup` — list items deferred by goal-met or --turns cap. Followup, /// `/summary [filename]` — render the run's report.md + @@ -105,6 +113,16 @@ pub fn parse_command(line: &str) -> Command { clear: rest.split_whitespace().any(|tok| tok == "--clear"), }, "plan" => Command::Plan, + "resume" => Command::Resume { + path: { + let t = rest.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }, + }, "followup" | "followups" | "deferred" => Command::Followup, "summary" => Command::Summary { filename: rest.split_whitespace().next().map(|s| s.to_string()), @@ -227,6 +245,21 @@ mod tests { assert_eq!(parse_command("/plan"), Command::Plan); } + #[test] + fn parses_resume_without_path() { + assert_eq!(parse_command("/resume"), Command::Resume { path: None }); + } + + #[test] + fn parses_resume_with_path() { + assert_eq!( + parse_command("/resume /tmp/foo.json"), + Command::Resume { + path: Some("/tmp/foo.json".into()) + } + ); + } + #[test] fn parses_followup_and_deferred() { assert_eq!(parse_command("/followup"), Command::Followup); diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index b4ed9a7..0b96b65 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -536,13 +536,24 @@ impl Session { .await; } - /// Load a prior session from `cfg.persist_path` and seed the - /// manager + deferred list. Called once at REPL startup. Returns - /// `Ok(Some(state))` on a successful resume, `Ok(None)` when - /// there's nothing to resume (no persist_path or file absent), - /// and `Err` on parse / I/O failure. + /// Load a prior session from `cfg.persist_path` (or an + /// explicit override) and seed the manager + deferred list. + /// Called once at REPL startup when `--resume` was passed, and + /// by the `/resume` command. Returns `Ok(Some(state))` on a + /// successful resume, `Ok(None)` when there's nothing to + /// resume (no persist path or file absent), and `Err` on parse + /// / I/O failure. pub async fn resume_state(&self) -> Result> { - let Some(path) = self.cfg.persist_path.as_ref() else { + self.resume_state_from(self.cfg.persist_path.as_deref()).await + } + + /// `resume_state` with an explicit source path override. `None` + /// falls back to `cfg.persist_path`. + pub async fn resume_state_from( + &self, + override_path: Option<&Path>, + ) -> Result> { + let Some(path) = override_path.or(self.cfg.persist_path.as_deref()) else { return Ok(None); }; let state = match kres_core::SessionState::load(path) { @@ -1623,6 +1634,7 @@ impl Session { } } Command::Plan => self.cmd_plan().await, + Command::Resume { path } => self.cmd_resume(path).await, Command::Followup => self.cmd_followup().await, Command::Summary { filename } => self.cmd_summary(filename, false).await, Command::SummaryMarkdown { filename } => { @@ -2431,6 +2443,76 @@ impl Session { } } + /// `/resume [PATH]` — load a persisted snapshot from disk. + /// Selection order: + /// 1. Explicit `PATH` argument when given. + /// 2. `/session.json.prev` — the backup kres moves + /// aside on startup when `--resume` was not passed. + /// 3. `/session.json` — the live file. Useful only + /// before any state-mutating command in this session, + /// since after that point it reflects the current run. + /// + /// Overwrites the current in-memory plan / todo / deferred / + /// counter. Operators who have already submitted prompts in + /// this session should expect to lose that work; no merge. + async fn cmd_resume(&self, path: Option) { + let chosen: std::path::PathBuf = match path.as_deref() { + Some(p) => std::path::PathBuf::from(p), + None => { + // Derive the backup + live paths from cfg.persist_path. + let Some(live) = self.cfg.persist_path.as_ref() else { + println!( + "/resume: no persist path configured (kres was started \ + without a results dir)" + ); + return; + }; + // Same-dir, same-stem, extra ".prev" extension. + let mut prev = live.clone(); + let prev_name = match live.file_name() { + Some(n) => format!("{}.prev", n.to_string_lossy()), + None => { + println!("/resume: persist path has no filename"); + return; + } + }; + prev.set_file_name(prev_name); + if prev.exists() { + prev + } else if live.exists() { + live.clone() + } else { + println!( + "/resume: neither {} nor {} exists — nothing to load", + prev.display(), + live.display() + ); + return; + } + } + }; + match self.resume_state_from(Some(&chosen)).await { + Ok(Some(state)) => { + println!( + "/resume: loaded {} ({} todo, {} deferred, turns done={})", + chosen.display(), + state.todo.len(), + state.deferred.len(), + state.completed_run_count, + ); + if let Some(ref p) = state.last_prompt { + println!("/resume: last prompt: {}", truncate(p, 80)); + } + } + Ok(None) => { + println!("/resume: {} is missing or empty", chosen.display()); + } + Err(e) => { + println!("/resume: {e}"); + } + } + } + /// `/plan` — show the current plan, produced by `define_plan` /// when the operator's last top-level prompt was submitted. /// Prints each step with its id + live status; the status @@ -3635,6 +3717,7 @@ fn print_help() { println!(" /cost show API token usage"); println!(" /todo show the todo list"); println!(" /plan show the current plan (produced by define_plan)"); + println!(" /resume [PATH] load a persisted session.json (backup, live, or PATH)"); println!(" /report write findings report (markdown)"); println!(" /load submit a file's contents as the next prompt"); println!(" /edit open $EDITOR on a scratch file, submit on save"); diff --git a/kres/src/main.rs b/kres/src/main.rs index 2b506d3..8fca766 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -101,6 +101,17 @@ struct ReplArgs { /// cap still wins there. #[arg(long, default_value_t = false)] follow: bool, + /// Resume from a prior `session.json` in the results dir. + /// When false (default), kres ignores any existing session.json + /// and starts clean — even when `--results DIR` points at a + /// directory that has one. Pass `--resume` to explicitly load + /// the persisted plan + todo + deferred + counter state. This + /// is off by default because an accidentally-shared results + /// dir between runs would otherwise bleed prior state into a + /// new session. When a session.json exists but `--resume` is + /// absent, kres prints a hint pointing at the file. + #[arg(long, default_value_t = false)] + resume: bool, /// Directory for all three artifact files (findings.json, /// report.md, todo.md). Defaults to ~/.kres/sessions//. /// Per-file flags (--findings/--report/--todo) still override. @@ -674,25 +685,83 @@ async fn run_repl(args: ReplArgs) -> Result<()> { persist_path, }; let mut session = Session::new(mgr, cfg); - // Resume from a prior session.json when the operator pointed at - // an existing results dir. `resume_state` is a no-op when the - // file isn't there, so a fresh session starts empty. - match session.resume_state().await { - Ok(Some(state)) => { + // Resume from a prior session.json ONLY when `--resume` was + // passed. Without the flag, any existing session.json is left + // untouched on disk and the REPL starts clean — this avoids + // silently inheriting a prior session's plan/todo/deferred + // state when the operator re-uses a results dir by accident. + // When the flag is absent but a session.json is present, log a + // hint so the operator knows the state is available. + if args.resume { + // Prefer the live session.json; fall back to + // session.json.prev when the live file is missing. The + // backup is what a prior run-without-`--resume` moved + // aside, so `--resume` on the next launch should pick it + // up rather than telling the operator there is nothing + // to load. + let live = results_dir.join("session.json"); + let backup = results_dir.join("session.json.prev"); + let chosen: Option = if live.exists() { + Some(live) + } else if backup.exists() { kres_core::async_eprintln!( - "resume: {} todo item(s), {} deferred, turns done={}", - state.todo.len(), - state.deferred.len(), - state.completed_run_count + "resume: session.json missing; loading {} instead", + backup.display() ); - if let Some(ref prompt) = state.last_prompt { - let short: String = prompt.chars().take(80).collect(); - kres_core::async_eprintln!("resume: last prompt: {}", short); + Some(backup) + } else { + None + }; + let load_result = match chosen.as_deref() { + Some(p) => session.resume_state_from(Some(p)).await, + None => Ok(None), + }; + match load_result { + Ok(Some(state)) => { + kres_core::async_eprintln!( + "resume: {} todo item(s), {} deferred, turns done={}", + state.todo.len(), + state.deferred.len(), + state.completed_run_count + ); + if let Some(ref prompt) = state.last_prompt { + let short: String = prompt.chars().take(80).collect(); + kres_core::async_eprintln!("resume: last prompt: {}", short); + } + } + Ok(None) => { + kres_core::async_eprintln!( + "resume: no session.json or session.json.prev in {} — starting clean", + results_dir.display() + ); + } + Err(e) => { + kres_core::async_eprintln!("resume: {e}"); } } - Ok(None) => {} - Err(e) => { - kres_core::async_eprintln!("resume: {e}"); + } else { + let session_json = results_dir.join("session.json"); + if session_json.exists() { + // Move the prior snapshot to session.json.prev so the + // first reaper tick that writes this session's fresh + // state does not destroy it. `/resume` inside the REPL + // reads this backup when the live session.json matches + // the current in-memory state. + let backup = results_dir.join("session.json.prev"); + match std::fs::rename(&session_json, &backup) { + Ok(()) => kres_core::async_eprintln!( + "note: prior session snapshot moved to {}; \ + starting clean. Type /resume (or restart with \ + --resume) to load it back.", + backup.display() + ), + Err(e) => kres_core::async_eprintln!( + "note: {} exists but could not be moved aside ({e}); \ + the first reaper tick will overwrite it. Pass \ + --resume next time to load prior state.", + session_json.display() + ), + } } } From af8d38dbdd87710e7da3f40e2f38c2270e6a56eb Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 10:41:20 -0700 Subject: [PATCH 27/76] skills: note subsystem paths in kernel skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the kernel skill directs the fast agent to read subsystem.md and load matching subsystem guides, the paths inside that file are relative to @REVIEW_PROMPTS@/kernel/subsystem/ — not the top-level @REVIEW_PROMPTS@/kernel/ directory. Spell it out so the agent adjusts the paths when following the guide chain. Signed-off-by: Chris Mason --- skills/kernel.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/kernel.md b/skills/kernel.md index f5b3d02..bce2564 100644 --- a/skills/kernel.md +++ b/skills/kernel.md @@ -27,5 +27,7 @@ context files from `@REVIEW_PROMPTS@/kernel/`: 1. Always read `technical-patterns.md` before loading subsystem specific files 2. Read `@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` and load matching subsystem - guides and critical patterns + guides and critical patterns. IMPORTANT. Files referenced in subsystem.md + are under @REVIEW_PROMPTS@/kernel/subsystem, you'll need to adjust the paths + as you read them. From 8074ed4ebeff6dc26616117e6b4f3f1e254eb43a Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 10:56:04 -0700 Subject: [PATCH 28/76] README: break into topic docs, keep overview + quick start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md had grown to 736 lines covering the agent flow, parallel-lens review, `--turns` semantics, coding mode, action allowlist, review-prompts repo integration, semcode MCP, configuration, system-prompt overrides, slash-command templates, CLI reference, workspace layout, and pre-commit hook. Readers hit a wall when all they wanted was how to get a review running. Split into one file per topic: - NEWS.md (news section). - docs/agents.md (fast / main / slow / todo / merger flow + "Building up a larger review"). - docs/review-template.md (the `/review` slash-command and its five-lens fan-out). - docs/coding-tasks.md (reproducers, `code_output`, `code_edits`, `bash` verify). - docs/summary.md (`/summary`, `--summary`, bug-report format). - docs/turns-and-follow.md (`--turns`, `--follow`, stop modes). - docs/action-allowlist.md (`--allow`, precedence, typo detect). - docs/configuration.md (`~/.kres/`, model precedence, system prompt overrides). - docs/commands.md (slash-command templates + disk overrides). - docs/review-prompts.md (external review-prompts repo). - docs/semcode.md (semcode-mcp integration). - docs/cli.md (every CLI flag + REPL command). - docs/development.md (workspace layout, build/test/lint, pre-commit hook, wire-format references). README.md drops to 105 lines: title, a "Why kres exists" paragraph that names each role and what it's for, three-step quick start, and a link hub into the docs. Content is not rewritten — each section keeps its existing prose so the git history stays traceable. Signed-off-by: Chris Mason --- NEWS.md | 20 + README.md | 799 ++++----------------------------------- docs/action-allowlist.md | 79 ++++ docs/agents.md | 66 ++++ docs/cli.md | 58 +++ docs/coding-tasks.md | 63 +++ docs/commands.md | 57 +++ docs/configuration.md | 111 ++++++ docs/development.md | 55 +++ docs/review-prompts.md | 32 ++ docs/review-template.md | 83 ++++ docs/semcode.md | 58 +++ docs/summary.md | 28 ++ docs/turns-and-follow.md | 46 +++ 14 files changed, 840 insertions(+), 715 deletions(-) create mode 100644 NEWS.md create mode 100644 docs/action-allowlist.md create mode 100644 docs/agents.md create mode 100644 docs/cli.md create mode 100644 docs/coding-tasks.md create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/review-prompts.md create mode 100644 docs/review-template.md create mode 100644 docs/semcode.md create mode 100644 docs/summary.md create mode 100644 docs/turns-and-follow.md diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000..be10533 --- /dev/null +++ b/NEWS.md @@ -0,0 +1,20 @@ +# NEWS + +## April 22 + +Agent system prompts and slash-command templates are now embedded +in the kres binary — rebuilding kres refreshes them. `setup.sh` +no longer copies `*.system.md`, `bug-summary*.md`, or +`review-template.md` anywhere. + +Stale files left under `~/.kres/prompts/` from earlier installs +are ignored and safe to delete. See +[docs/configuration.md](docs/configuration.md) for the override +paths and [docs/commands.md](docs/commands.md) for the +slash-command templates. + +## April 21 + +New support for writing patches: `--prompt 'fix …'` classifies +the task as **coding mode** and produces in-place edits plus +fresh files. See [docs/coding-tasks.md](docs/coding-tasks.md). diff --git a/README.md b/README.md index d32419c..e4db02f 100644 --- a/README.md +++ b/README.md @@ -1,736 +1,105 @@ # kres Kernel code RESearch agent — an LLM-driven multi-agent REPL for -reviewing, auditing, and finding bugs in C source trees (the kernel -is the primary target). - -# NEWS - -April 22: Agent system prompts and slash-command templates are -now embedded in the kres binary — rebuilding kres refreshes -them. `setup.sh` no longer copies `*.system.md`, `bug-summary*.md`, -or `review-template.md` anywhere. - -Stale files left under `~/.kres/prompts/` from earlier installs -are ignored and safe to delete. See "System prompts" and -"Slash-command templates" below. - -April 21: There's new support for writing patches, more details below. +reviewing, auditing, and finding bugs in C source trees. The +Linux kernel is the primary target; any large C codebase with +source-level tooling works too. + +## Why kres exists + +A single LLM call over a C source file produces a prose summary +that reads well but misses bugs — it has no structured way to +dedup what it already covered, no budget left after loading the +code for deep thinking, and no memory across questions. kres +splits the job across cooperating roles: + +- **fast** scopes the work, picks the code to look at, and emits + a structured brief for deeper analysis. +- **main** fetches that code via MCP tools, grep, read, git — + treating code navigation as a first-class tool-call surface + rather than free-form text manipulation. +- **slow** runs the deep analysis with a prepared context and + previous findings in hand, so the expensive model's tokens go + to bug-hunting rather than chasing files. +- **todo** dedups follow-up questions, reprioritises, and keeps a + running list across turns so a single prompt can drive 30+ + tasks without losing coverage. +- **merger** folds each task's findings into a cumulative, + deduplicated bug list; old findings get `invalidated` when a + later one supersedes them. + +The REPL ties these together: one `--prompt 'review: X'` seeds a +lens fan-out that audits `X` under five parallel angles (object +lifetime, memory, bounds, races, general correctness), and +follow-up tasks chase the threads the slow agent flags — all +with persistent plan, todo list, and findings that survive +interruptions. + +See [docs/agents.md](docs/agents.md) for the task flow and +[docs/review-template.md](docs/review-template.md) for the +parallel-lens review. ## Quick start -1. Build: +1. **Build**: + ``` cargo build --release ``` -2. Populate `~/.kres/` from this repo's shipped configs by running - `setup.sh`: +2. **Populate `~/.kres/`** from shipped configs: + ``` ./setup.sh --fast-key $FAST_API_KEY --slow-key $SLOW_API_KEY ``` - Each `--fast-key` / `--slow-key` argument accepts either a literal - API key string or a path to an existing key file (contents trimmed - and used verbatim). Running `setup.sh --help` lists the full set - of options. - - The script copies `configs/*.json`, `configs/prompts/`, and - `skills/` into `~/.kres/`, substitutes `@FAST_KEY@` / `@SLOW_KEY@` - placeholders in the installed agent configs with the keys you - passed, and installs `mcp.json` only when `semcode-mcp` is found - on your `PATH` (or you pass `--semcode PATH`). It also installs - the kernel skill if it can find a review-prompts tree — pass - `--review-prompts /path/to/review-prompts` if you want that on - from the start. - Model selection lives in `~/.kres/settings.json`, one key per agent - role (`fast`, `slow`, `main`, `todo`). `setup.sh` writes that file - from its own flags: - - `--slow MODEL` sets the slow-agent model (default - `claude-opus-4-7`). - - `--model MODEL` sets the fast / main / todo model (default - `claude-sonnet-4-6`). - The shipped agent configs do not hardcode a model; `settings.json` - is the single source of truth. An operator who adds `"model": …` - back to a specific agent config will override `settings.json` for - just that agent (see the precedence note below). + Each key arg accepts a literal API key or a path to a key + file. `setup.sh --help` lists every option — model picks + (`--slow`, `--model`), `--semcode PATH`, + `--review-prompts PATH`, `--overwrite`, and more. The shipped + defaults use `claude-opus-4-7` for the slow agent and + `claude-sonnet-4-6` for the fast / main / todo roles; + `~/.kres/settings.json` is the single source of truth for + model selection. - Running `--slow` and `--model` against the same model id is fine - and often what you want if you only have one model's credentials. - The difference between "fast" and "slow" work is driven by the - per-agent system prompts shipped under `configs/prompts/` and the - amount of context each agent receives, not by the model choice — - so pointing both at the same id still produces the full - fast/main/slow pipeline, each agent thinking as hard or as lightly - as its prompt asks. Using two different models is an optimisation - for cost or latency, not a correctness requirement. +3. **Run a review** from a kernel tree: - `--overwrite` is required to replace any file that already exists - under `~/.kres/`; without it `setup.sh` is idempotent and reports - each skipped file. - -3. Run a review from a kernel tree: ``` cd linux kres --results review --prompt 'review: fs/btrfs/ctree.c' --turns 2 ``` -The `--prompt 'review: fs/btrfs/ctree.c'` form is a two-part -prompt: the token `review` names the slash-command template -embedded in the kres binary (source: -`configs/prompts/review-template.md`), and the rest of the -string is the specific target. kres splices the target onto the -front of the template body to produce a full prompt covering -object lifetime, memory safety, bounds checks, races, and -general bugs in the named code. - -Two equivalent forms — pick whichever reads better: - -``` -kres --prompt 'review: fs/btrfs/ctree.c' -kres --prompt '/review fs/btrfs/ctree.c' -``` - -Both resolve via `kres_agents::user_commands::lookup("review")`, -which prefers `~/.kres/commands/review.md` on disk (the operator -override path) and falls back to the embedded copy. Drop a file -at `~/.kres/commands/.md` to add a new command; use the -same `--prompt "name: extra"` or `--prompt "/name extra"` form -to invoke it. - -Legacy compatibility: `--prompt "word: extra"` still falls back -to `~/.kres/prompts/-template.md` when no matching -`~/.kres/commands/.md` exists and the name isn't one of -the embedded commands — operators with custom `-template.md` -files from before the refactor keep working. - -Note: the template is invoked only when `review` appears as the -colon-terminated leading word (`"review:..."`) or as the -slash-prefixed leading word followed by whitespace -(`"/review ..."`). Free-form text that happens to contain those -character sequences elsewhere (e.g. `"what caused the review: ..."`) -is submitted verbatim — the split is anchored to the start of -the prompt. - -### Parallel lenses inside `review-template.md` - -The shipped template is more than a prose prompt — each of its -markdown todo bullets is a **lens**: - -``` -- [ ] **[investigate]** object lifetime: #lifetime -- [ ] **[investigate]** memory allocations: #memory -- [ ] **[investigate]** bounds checks ... #bounds -- [ ] **[investigate]** races: #races -- [ ] **[investigate]** general: #general -``` -(`configs/prompts/review-template.md`) - -`kres_agents::parse_prompt_file` -(`kres-agents/src/prompt_file.rs:28-98`) turns each bullet into a -`LensSpec` (id, kind, name, reason) and installs them as -**session-wide lenses**. For every task, kres then fans out one -slow-agent call per lens over the *same* gathered symbols and -source sections — five parallel analyses in the case of the shipped -template — and runs a consolidator pass that dedupes the findings -across lenses before the merger folds them into the cumulative -list (`kres-core/src/lens.rs:1-7`). - -That parallelism is what makes a single `review:` run productive: -instead of the slow agent juggling lifetime + memory + bounds + -races + general bugs in one response, each angle gets its own -focused call with the full context, and overlap between findings -is resolved at consolidation time. Indented sub-bullets under a -lens bullet fold into its `reason` field and become extra guidance -the slow agent sees on that specific lens (see the sub-bullets -under `object lifetime` and `memory allocations` in the template). - -To add or remove angles for your own reviews, drop a customised -copy of the review template at `~/.kres/commands/review.md` — it -takes precedence over the embedded copy at load time. Dropping a -new `.md` alongside it (e.g. `~/.kres/commands/audit.md`) -adds a `/audit` slash-command you can invoke via -`--prompt "audit: target"` or `--prompt "/audit target"`. - -`--results review` tells kres where to keep the run's artifacts: -`findings.json` (plus `findings-N.json` history snapshots), the -running narrative `report.md`, and the rendered `bug-report.txt` -when `/summary` fires. Without `--results`, kres picks -`~/.kres/sessions//` automatically. - -## `--turns` and `--follow`: stopping the run - -`--turns` controls when kres decides a non-interactive run is "done". -A "completed task" throughout this section means a unit that ran all -the way through fast → main → slow and produced a non-empty analysis -(`kres-core/src/task.rs:309-311`). - -- **`--turns N` (N ≥ 1)** — stop after N completed tasks. Useful for - a single focused question (`--turns 1`) or a time-boxed review - (`--turns 5` etc.). The REPL exits as soon as the Nth task - finishes, regardless of what the goal agent or the followup queue - look like. `--follow` has no effect in this mode; the run-count - cap wins. - -- **`--turns 0` (the default)** — no run-count cap. kres trusts the - goal agent: after every task the goal agent checks the accumulated - analysis against the per-task goal; when it declares the goal met, - its handler drains the todo list and the reaper exits on the next - tick (nothing is active, nothing is pending). Until then kres - keeps dispatching the followup tasks the goal check spawns. - - - Add `--follow` to layer a cost cap on top: if 3 consecutive - analysis-producing runs fail to grow the findings list, exit - even if the goal agent is still saying "not met". Use this when - you want a hard ceiling on how long kres will keep pulling on - threads. - - (`kres-repl/src/session.rs` — see the `turns_limit == 0` branch in - the reaper for the exact predicates. If you run without a - `main-agent.json`, no goal agent is wired up and kres falls back - to "stop when the active batch finishes"; `--follow` switches that - fallback to "drain the todo list with the 3-run stagnation cap".) - -On any `--turns` exit path — run-count cap, goal-met drain, or -stagnation cap — kres - -1. cancels any in-flight work, -2. runs `/summary` automatically, producing `bug-report.txt` - (`bug-report.md` with `--markdown`) in the results directory, or - in the current working directory when `--results` was not given, - and -3. exits. - -Remaining pending or blocked todo items are moved to the "deferred" -list; `/followup` shows them if you re-enter the REPL later, and -`/continue` will dispatch them. - -## Flow of work between the agents - -A task goes through three agents, all configured from -`~/.kres/`: - -- **fast** (`fast-code-agent.json`): scopes the task, figures out - what source kres needs to look at, and emits a structured brief. - When it's ready it returns a list of "followups" — concrete fetch - requests (grep, file read, semcode symbol/callchain, git log) - that the main agent should run. - -- **main** (`main-agent.json`): the data fetcher. It takes the - fast agent's followups and dispatches them to local tools and to - any MCP servers configured in `mcp.json` (semcode in particular). - The output is funnelled back into the fast agent for another - round. This fast↔main loop runs until the fast agent says - `ready_for_slow`, or the `--gather-turns` cap is reached. - -- **slow** (`slow-code-agent-.json`, default `sonnet`): the - deep analyser. It receives the gathered symbols and file sections, - the cumulative findings from earlier tasks, and the task brief, - then produces a new analysis and any new findings. Slow-agent - output is cheap prose plus structured findings records. - -- **todo** (`todo-agent.json`): after the slow agent returns, the - todo agent dedups its followup suggestions against the existing - pending/done todo list and emits an updated list. This is what - drives larger reviews — see below. - -- **merger**: a non-agent fast-client call that merges the new - task's findings into the cumulative findings list. Duplicates get - folded; old findings that a new one supersedes get marked - `invalidated`. - -All inference happens over the Anthropic streaming API. Every -round-trip is logged to `.kres/logs//` so you can -inspect what each agent saw and replied. - -## Building up a larger review - -A single `--prompt 'review: fs/btrfs/ctree.c'` call seeds exactly -one task. That task's slow-agent response usually contains followup -suggestions like "investigate memory lifetime of the path argument" -or "check callers of btrfs_search_slot". The todo agent turns those -into todo items. - -From there: - -- **`/next`** runs the first pending todo item as its own task. -- **`/continue`** dispatches every pending todo item. -- **auto-continue**: when there are pending todos and no active - tasks, kres launches `/continue` automatically after 5 seconds of - idle. You can override the idle by typing anything, including - `/stop`. - -Each task feeds back into the same pipeline: fast → main → slow → -merger, plus the todo agent deduping any new followups against the -existing list. The goal agent (a special mode of the main-agent -model) periodically checks whether the original prompt has been -satisfied; if yes, work stops even if followups remain. - -A full review of a substantial source file usually takes between 5 -and 50 task runs, depending on how branchy the code is and how -aggressive the slow agent is about producing followup questions. -`--turns` caps that; `/quit` lets you bail out early and resume -later. - -## Summary output - -After each task, kres appends the slow agent's narrative to -`/report.md` and rewrites `/findings.json` with -the cumulative merged list (the prior turn's canonical file is -copied to `findings-N.json` first, so you have the history). - -At the end of a run you get a plain-text bug report via `/summary` -(or automatically on `--turns` exit, or separately with -`kres --summary --results `). That run: - -- Picks up `/prompt.md` (saved on the first submit so - subsequent `/summary` or `--summary` invocations know the original - question), `/report.md`, and `/findings.json`. -- Uses the fast agent with the `summary` slash-command template - (embedded in the kres binary; overridable at - `~/.kres/commands/summary.md`) as a dedicated system prompt. - `--markdown` selects the `summary-markdown` variant instead. -- Orders the resulting sections by `bug-severity` — `high` → - `medium` → `low` → `latent` → `unknown` — with one section per - bug, each led by `Subject:`, `bug-severity:`, and `bug-impact:` - lines. -- Writes the result to `/bug-report.txt` (or - `bug-report.txt` in the current working directory if you did not - pass `--results`). - -You can point `--template PATH` at a custom file to override the -shipped summariser prompt without rebuilding. - -## Coding tasks: reproducers and in-place fixes - -Not every prompt is a review. Ask kres `--prompt 'write a -reproducer for the UAF in net/sched/cls_bpf.c'` or `--prompt 'fix -the missing frag-free in bnxt_xdp_redirect'` and the goal agent -classifies the task as **coding mode** instead of analysis. Coding -mode swaps out the review pipeline's lens fan-out and findings -consolidator for a single slow-agent call whose job is to produce -source code. Two output channels: - -- **`code_output`** — a list of `{path, content, purpose}` - records. Each entry is a full file body that the reaper writes - under `/code/` via tmp + rename. Use this for - fresh artifacts (reproducers, test harnesses, trigger programs, - scratch fixes that rewrite a whole file). - -- **`code_edits`** — a list of `{file_path, old_string, - new_string, replace_all}` records, same shape as Claude Code's - Edit primitive. The reaper applies each edit in order via - `kres_agents::tools::edit_file`: `old_string` must appear - exactly once in the current file contents (unless - `replace_all: true`), and the file is rewritten atomically via - tmp + rename (`kres-agents/src/tools.rs`). This is the - preferred channel for surgical one-line fixes — the - `old_string` anchor forces the slow agent to quote bytes from - the real file rather than reconstruct them from summary-level - descriptions. Each edit's result (replacement count for - success, verbatim error message for failure) is folded into - the task's analysis trailer under `Edits applied (N/M[, K - FAILED]):` so the next slow-agent turn can see which edits - landed and correct any that didn't. - -The slow-code prompt (`configs/prompts/slow-code-agent-coding.system.md`) -enforces two rules that matter in practice: the verbatim current -contents of the file being fixed must be in the gathered symbols -or context before any edit is emitted (a `read` followup is -requested and waited on otherwise — the slow agent is explicitly -told not to fix from memory), and a multi-edit batch applies in -emission order with each `old_string` matching the file state -AFTER prior edits in the same batch have landed. - -**Verification via `bash`** — the slow agent can emit a `bash` -followup (e.g. `cc -o repro repro.c && ./repro`, `make -C test`) -to build and run what it just wrote. The main agent executes it -from the workspace root, captures `[exit N]` + stdout + stderr, -and feeds the result back. This is the one flow where `bash` is -genuinely useful — but it is OFF by default (see "Action -allowlist" below) and must be explicitly enabled for the session. - -On a coding run you typically invoke kres with: - -``` -kres --prompt 'write a reproducer for the stack OOB in x_tables' \ - --allow bash \ - --results repro-run -``` - -Artifacts land in `/code/` (for `code_output`) and -in-place under `` (for `code_edits`). The ordinary -`report.md` + `findings.json` ledger continues to accumulate -narrative; coding tasks skip the findings-merger path since their -output is source files, not bug records. - -## Action allowlist - -The main agent's non-MCP tools are gated by a session-wide -allowlist. Defaults: `grep`, `find`, `read`, `git`, `edit`. -`bash` is **OFF by default** because operators report it being -reached for as a general escape hatch for things the typed tools -already cover (`bash sed` for range reads, `bash find` for -filename locates). An action whose `type` isn't in the allowlist -is rejected at dispatch time with a message naming the allowed -set and pointing at the two ways to fix it. - -**Three precedence levels:** - -1. `--allow ACTION` CLI flags — additive on top of whatever the - files resolved to. Repeatable (`--allow bash --allow git`) or - comma-separated (`--allow bash,git`). The special value - `--allow all` enables every action type the dispatcher knows. -2. Per-project `/.kres/settings.json` — overrides global - values field-by-field; an explicit allowlist replaces rather - than unions with the global one. -3. Global `~/.kres/settings.json` — the default resting place - for a per-user policy. - -**Example — enable bash for this session only:** - -``` -kres --allow bash --prompt 'reproduce the RDS UAF' -``` - -**Example — enable bash permanently in settings.json:** - -```json -{ - "actions": { - "allowed": ["grep", "find", "read", "git", "edit", "bash"] - } -} -``` - -**Example — deny every non-MCP action (tight lockdown, leaves -only MCP tools available to the main agent):** - -```json -{ - "actions": { - "allowed": [] - } -} -``` - -The empty array is the explicit "lock it down" signal — kres -dispatcher enforces it and does not fall back to defaults. -A missing or absent `actions.allowed` (i.e. `null` or the key -unset) is different: it means "use the built-in default list". - -**Typo detection** — tokens in `--allow` or `actions.allowed` -that aren't recognised action names produce a startup warning -with a closest-match suggestion (Levenshtein ≤ 2), e.g. -`settings: unknown action token 'bsah' (--allow) — did you mean -'bash'? known: grep, find, read, git, edit, bash, mcp`. Unknown -tokens are dropped rather than silently inserted, so a typo -never leaves a dead entry in the allowlist. - -**Startup banner** — when a main-agent config is resolved, kres -prints the effective allowlist on startup and distinguishes -"bash disabled by default" from "bash disabled by explicit -allowlist in settings.json". Both point at `--allow bash` as the -fix but the wording respects the source of the decision. - -MCP tools are gated separately (by mcp.json server registration, -not this allowlist) and don't enter the allowlist's dispatch -path. `--allow mcp` is a no-op and does not produce a typo -warning. - -## Review prompts - -kres can leverage the kernel review prompts for additional subsystem knowledge. -These live in a separate repo: - -https://github.com/masoncl/review-prompts - -The shipped kernel skill (`skills/kernel.md`) is a thin loader: it -references `@REVIEW_PROMPTS@/kernel/technical-patterns.md` as a -mandatory read on every slow-agent turn, plus -`@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` as an index into -per-subsystem guides. `setup.sh` substitutes `@REVIEW_PROMPTS@` with -an on-disk path at install time (see `skills/kernel.md:8`, -`skills/kernel.md:17`, `skills/kernel.md:29`). - -Point `setup.sh` at your clone so the skill can resolve those files: - -``` -./setup.sh --fast-key $FAST_API_KEY --slow-key $SLOW_API_KEY \ - --review-prompts /path/to/review-prompts -``` - -Without a resolvable path, `setup.sh` leaves the kernel skill -uninstalled (`setup.sh:386-389`) — the agents will still run, but the -slow agent won't have the pattern catalogue or subsystem context, so -findings tend to be shallower and miss conventions that are obvious -to someone who has read the pattern files. - -If a path wasn't given explicitly, `setup.sh` peeks at -`~/.claude/skills/kernel/SKILL.md` and offers the first -`review-prompts` path it finds there (`setup.sh:338-372`); pass -`--review-prompts PATH` explicitly to bypass the prompt. - -## semcode - -The main agent's code-navigation and seawrching can be enhanced by semcode: -server: - -https://github.com/facebookexperimental/semcode - -When a `semcode-mcp` binary is installed, `setup.sh` writes an -`mcp.json` that launches it as an MCP child: - -``` -{ - "mcpServers": { - "semcode": { "command": "semcode-mcp" } - } -} -``` - -(`configs/mcp.json`). - -kres works without semcode — the main agent can already answer code -questions with `read`, `grep`, and `git` against the workspace -(`CLAUDE.md:9,16`). When semcode is available, the main agent gets a -function/type/callchain-aware index to ask instead of deriving the -same information from raw regex. - -Tools semcode exposes that the main agent will call when wired up: - -- Function- and type-level lookups: `find_function`, `find_type`, - `find_callers`, `find_calls`, `find_callchain`, `grep_functions`. -- Commit- and branch-level helpers: `find_commit`, - `compare_branches`, `diff_functions`, `list_branches`. -- Vector-indexed search: `vgrep_functions`, - `vcommit_similar_commits`, `vlore_similar_emails`, `lore_search`. - -Raw semcode symbol text is normalised back into a uniform JSON -shape by `parse_semcode_symbol` (`kres-agents/src/symbol.rs:52-59`) -before reaching the fast/slow agents. - -**When it helps**: whole-program questions that read/grep can only -approximate — "who calls `btrfs_search_slot`", "what does the -definition of `struct inode` look like on this branch", "show me -every change to this function in the last 1000 commits". Without -semcode the main agent still answers those, just via more grep -round-trips with more false positives. - -**Install**: either drop `semcode-mcp` on your `PATH` before running -`setup.sh` (it auto-installs `mcp.json`, `setup.sh:265-269`) or pass -`--semcode PATH/TO/semcode-mcp` explicitly (`setup.sh:41-45`). -`--semcode ""` force-skips the MCP install even when the binary is -on `PATH`. kres's `.gitignore` excludes a `/.semcode.db/` directory -at the repo root (`.gitignore:4`) — that's semcode's on-disk index -cache; consult the semcode repo for details on how it's populated -and invalidated. - -## Config directory: `~/.kres/` - -`kres repl` resolves every optional config path in this order: - -1. explicit CLI flag (e.g. `--fast-agent /path/to/fast.json`) -2. same filename under `~/.kres/` - -Default filenames looked up in `~/.kres/`: - -| Flag | Default under `~/.kres/` | -|-------------------|----------------------------------| -| `--fast-agent` | `fast-code-agent.json` | -| `--slow` tag | `slow-code-agent-.json` | -| `--main-agent` | `main-agent.json` | -| `--todo-agent` | `todo-agent.json` | -| `--mcp-config` | `mcp.json` | -| `--skills` | `skills/` | -| `--findings` | `findings.json` | - -A missing file in `~/.kres/` is not an error — the "not configured" -branch fires as if the flag were absent. - -The `history` file is always written to `~/.kres/history` regardless -of other flags; it holds readline line-edit history. - -`~/.kres/settings.json` carries per-user default model ids per agent -role. `setup.sh --slow MODEL` / `--model MODEL` populate the slow -slot and the fast / main / todo slots respectively; default values -are `claude-opus-4-7` (slow) and `claude-sonnet-4-6` (the rest). - -Model-id precedence at runtime (see -`kres-repl/src/settings.rs::pick_model`): - 1. The agent config's explicit `"model"` field when present. - 2. The matching `settings.models.` string in - `~/.kres/settings.json`. - 3. `Model::sonnet_4_6()` — the built-in fallback when both of the - above are absent. - -The shipped agent configs no longer set `"model"`, so in a fresh -install step 2 drives the actual choice. Reintroducing a `"model"` -line in one of the agent configs still takes effect and overrides -settings.json for that agent only. - -## System prompts - -Agent `*.system.md` prompts (fast / slow / slow-coding / -slow-generic / main / todo) are compiled into the kres binary -via `include_str!` (see `kres-agents/src/embedded_prompts.rs`). -`setup.sh` does NOT install them on disk. Rebuilding kres -refreshes them. - -The shipped agent configs under `configs/*.json` reference -`system_file: "system-prompts/.system.md"`; the path is -resolved relative to the config file's directory, so at runtime -it becomes `~/.kres/system-prompts/.system.md`. - -Load order used by `AgentConfig::load`: - -1. **Disk override**: `~/.kres/system-prompts/`. If - this file exists and is non-empty it is used verbatim. -2. **Embedded**: the compiled-in copy keyed by basename. -3. **Error**: neither present → config load fails with a - message that names both paths. - -To customize an agent prompt for your own install, drop the -edited file at `~/.kres/system-prompts/`. The default -install has no files there; the embedded copies do all the work. - -Slash-command templates (`/review`, `/summary`, -`/summary-markdown`) live in a separate module -(`kres-agents/src/user_commands.rs`) with their own override -directory at `~/.kres/commands/` — see the next section. - -Why distinct directories? Older installs populated -`~/.kres/prompts/` directly from setup.sh (both `*.system.md` -and `bug-summary*.md`). Keeping the override in the same -directory would mean those leftover files shadow the embedded -defaults and produce stale behaviour after an upgrade. Two -fresh directory names sidestep that — a fresh kres reads only -the embedded defaults until the operator deliberately drops a -file under the new paths. Stale files under `~/.kres/prompts/` -are safe to delete (the slash-command loader still reads -`-template.md` from there as a back-compat fallback, but -will never find a filename matching one of the shipped embedded -commands there since setup.sh never writes those names to -`prompts/`). - -## Slash-command templates - -`review` / `summary` / `summary-markdown` are embedded -slash-command templates. Each has an `.md` body bundled in the -kres binary via `kres_agents::user_commands`, and an operator -can override or add commands by dropping a file at -`~/.kres/commands/.md`. - -Invocation paths (all three commands available in both places, -plus arbitrary operator commands dropped under -`~/.kres/commands/.md` are invocable the same way): - -| Command | CLI | REPL | -|--------------------|----------------------------------------------------|--------------------------------| -| `review` | `kres --prompt 'review: fs/btrfs/ctree.c'` or `kres --prompt '/review fs/btrfs/ctree.c'` | `/review fs/btrfs/ctree.c` | -| `summary` | `kres --summary --results DIR` | `/summary [filename]` | -| `summary-markdown` | `kres --summary --markdown --results DIR` | `/summary-markdown [filename]` | - -The `review:` and `/review` CLI forms compose the template body -with the trailing target; the `/review` REPL form does the same -composition through `user_commands::compose` and submits the -result as a new task. - -The shipped three: - -- `review` — the parallel-lens review template (see the - "Parallel lenses" section above). Invocation prepends the - operator's target to the template body. -- `summary` — the plain-text bug-report system prompt that - `/summary` and `kres --summary` pass to the fast agent. -- `summary-markdown` — the markdown-output variant selected by - `--markdown`. - -Adding your own: drop `~/.kres/commands/audit.md` and run -`kres --prompt 'audit: net/...'` or `kres --prompt '/audit -net/...'`. No rebuild needed — the disk override path is -consulted on every invocation. - -Load order (identical for every command): - -1. `~/.kres/commands/.md` on disk (operator override). -2. Embedded body in `kres_agents::user_commands` (for the three - shipped commands). -3. Fallback to the legacy `~/.kres/prompts/-template.md` - lookup when neither of the above hit — preserves existing - custom templates from before this refactor. -4. Nothing matched → treat `"name: extra"` as a verbatim prompt. - -Files that setup.sh still copies to `~/.kres/prompts/`: any -operator-authored `-template.md` the user drops into -`configs/prompts/` that isn't shadowed by an embedded command -of the same root name. The shipped `review-template.md`, -`bug-summary.md`, and `bug-summary-markdown.md` are NOT copied -(they're embedded); `configs/prompts/-template.md` for -any other `` is copied verbatim so custom templates from -before the refactor keep working via the legacy -`~/.kres/prompts/-template.md` fallback path. - -## Workspace layout - -``` -kres/ -├── Cargo.toml Rust workspace manifest -├── kres/ binary crate (`kres` command) -├── kres-core/ Task, TaskManager, shutdown, findings -├── kres-llm/ Anthropic streaming client + rate limiter -├── kres-mcp/ stdio JSON-RPC client for MCP servers -├── kres-agents/ fast / slow / main / todo / consolidator / merger -├── kres-repl/ readline UI, commands, signal handling -├── configs/ per-agent JSON configs (shipped defaults) -│ ├── fast-code-agent.json -│ ├── slow-code-agent-opus.json -│ ├── slow-code-agent-sonnet.json -│ ├── main-agent.json -│ ├── todo-agent.json -│ ├── settings.json -│ ├── mcp.json -│ └── prompts/ system prompts + review templates -├── skills/ domain-knowledge markdown fed to agents -│ └── kernel.md -├── docs/ JSON-schema docs for agent wire formats -│ ├── findings-json-format.md -│ ├── prompt-json-format.md -│ └── response-json-format.md -├── CLAUDE.md project instructions for Claude Code -├── setup.sh bootstrap ~/.kres/ from configs/ -├── .githooks/pre-commit runs cargo fmt + clippy on every commit -└── README.md -``` - -Build: `cargo build --release` -Test: `cargo test --workspace` -Lint: `cargo clippy --workspace --all-targets -- -D warnings` -Format check: `cargo fmt --all --check` - -## Pre-commit hook - -`.githooks/pre-commit` runs `cargo fmt --check` + `cargo clippy -D -warnings` on every commit. Enable it per-clone with: - -``` -git config core.hooksPath .githooks -``` - -## Supported CLI - -``` -kres test [--prompt ...] [--model ...] -kres turn -o [-i ] [other flags] -kres [--fast-agent ...] [--slow TAG | --slow-agent ...] [--main-agent ...] - [--todo-agent ...] [--mcp-config ...] [--skills DIR] - [--results DIR] [--findings PATH] [--report PATH] [--todo PATH] - [--prompt PROMPT] [--template PATH] [--turns N] - [--gather-turns N] [--stop-grace-ms MS] [--stdio] - [--allow ACTION]... [--summary] -``` - -Interactive REPL commands: `/help`, `/tasks`, `/findings`, `/stop`, -`/clear`, `/cost`, `/todo`, `/summary [FILE]`, `/summary-markdown [FILE]`, -`/review `, `/report `, `/load `, `/edit`, -`/reply `, `/next`, `/continue`, `/quit`. + `--prompt 'review: X'` invokes the embedded review template — + a five-lens parallel audit over the target. `--results DIR` + keeps the run's artifacts under `DIR/` (findings.json, + report.md, bug-report.txt). `--turns 2` stops after two + completed tasks; see + [docs/turns-and-follow.md](docs/turns-and-follow.md) for the + other stop modes. + +## Further reading + +- [NEWS.md](NEWS.md) — recent changes. +- [docs/agents.md](docs/agents.md) — fast / main / slow / todo / + merger flow and how follow-up tasks drive larger reviews. +- [docs/review-template.md](docs/review-template.md) — the + parallel-lens review flow behind `--prompt "review:"`. +- [docs/coding-tasks.md](docs/coding-tasks.md) — reproducer and + fix generation (`code_output`, `code_edits`, `bash` verify). +- [docs/summary.md](docs/summary.md) — `/summary`, + `kres --summary`, and the bug-report output format. +- [docs/turns-and-follow.md](docs/turns-and-follow.md) — when + kres decides a non-interactive run is done. +- [docs/action-allowlist.md](docs/action-allowlist.md) — which + non-MCP tools the main agent can dispatch and how to change + that. +- [docs/configuration.md](docs/configuration.md) — `~/.kres/` + layout, model selection, and system-prompt overrides. +- [docs/commands.md](docs/commands.md) — slash-command templates + (`/review`, `/summary`, operator-authored additions). +- [docs/review-prompts.md](docs/review-prompts.md) — integrating + the separate `review-prompts` repo with the kernel skill. +- [docs/semcode.md](docs/semcode.md) — semcode-mcp integration. +- [docs/cli.md](docs/cli.md) — every CLI flag and REPL command. +- [docs/development.md](docs/development.md) — workspace layout, + build / test / lint, pre-commit hook. diff --git a/docs/action-allowlist.md b/docs/action-allowlist.md new file mode 100644 index 0000000..3b08721 --- /dev/null +++ b/docs/action-allowlist.md @@ -0,0 +1,79 @@ +# Action allowlist + +The main agent's non-MCP tools are gated by a session-wide +allowlist. Defaults: `grep`, `find`, `read`, `git`, `edit`. +`bash` is **OFF by default** because operators report it being +reached for as a general escape hatch for things the typed tools +already cover (`bash sed` for range reads, `bash find` for +filename locates). An action whose `type` isn't in the allowlist +is rejected at dispatch time with a message naming the allowed +set and pointing at the two ways to fix it. + +## Three precedence levels + +1. `--allow ACTION` CLI flags — additive on top of whatever the + files resolved to. Repeatable (`--allow bash --allow git`) or + comma-separated (`--allow bash,git`). The special value + `--allow all` enables every action type the dispatcher knows. +2. Per-project `/.kres/settings.json` — overrides global + values field-by-field; an explicit allowlist replaces rather + than unions with the global one. +3. Global `~/.kres/settings.json` — the default resting place + for a per-user policy. + +## Examples + +**Enable bash for this session only:** + +``` +kres --allow bash --prompt 'reproduce the RDS UAF' +``` + +**Enable bash permanently in settings.json:** + +```json +{ + "actions": { + "allowed": ["grep", "find", "read", "git", "edit", "bash"] + } +} +``` + +**Deny every non-MCP action (tight lockdown, leaves only MCP +tools available to the main agent):** + +```json +{ + "actions": { + "allowed": [] + } +} +``` + +The empty array is the explicit "lock it down" signal — kres +dispatcher enforces it and does not fall back to defaults. +A missing or absent `actions.allowed` (i.e. `null` or the key +unset) is different: it means "use the built-in default list". + +## Typo detection + +Tokens in `--allow` or `actions.allowed` that aren't recognised +action names produce a startup warning with a closest-match +suggestion (Levenshtein ≤ 2), e.g. `settings: unknown action +token 'bsah' (--allow) — did you mean 'bash'? known: grep, +find, read, git, edit, bash, mcp`. Unknown tokens are dropped +rather than silently inserted, so a typo never leaves a dead +entry in the allowlist. + +## Startup banner + +When a main-agent config is resolved, kres prints the effective +allowlist on startup and distinguishes "bash disabled by default" +from "bash disabled by explicit allowlist in settings.json". +Both point at `--allow bash` as the fix but the wording respects +the source of the decision. + +MCP tools are gated separately (by mcp.json server registration, +not this allowlist) and don't enter the allowlist's dispatch +path. `--allow mcp` is a no-op and does not produce a typo +warning. diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..dbcd592 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,66 @@ +# Agents — flow of work between the fast, main, slow, and todo agents + +A task goes through three agents, all configured from `~/.kres/`: + +- **fast** (`fast-code-agent.json`): scopes the task, figures out + what source kres needs to look at, and emits a structured brief. + When it's ready it returns a list of "followups" — concrete fetch + requests (grep, file read, semcode symbol/callchain, git log) + that the main agent should run. + +- **main** (`main-agent.json`): the data fetcher. It takes the + fast agent's followups and dispatches them to local tools and to + any MCP servers configured in `mcp.json` (semcode in particular). + The output is funnelled back into the fast agent for another + round. This fast↔main loop runs until the fast agent says + `ready_for_slow`, or the `--gather-turns` cap is reached. + +- **slow** (`slow-code-agent-.json`, default `sonnet`): the + deep analyser. It receives the gathered symbols and file sections, + the cumulative findings from earlier tasks, and the task brief, + then produces a new analysis and any new findings. Slow-agent + output is cheap prose plus structured findings records. + +- **todo** (`todo-agent.json`): after the slow agent returns, the + todo agent dedups its followup suggestions against the existing + pending/done todo list and emits an updated list. This is what + drives larger reviews — see below. + +- **merger**: a non-agent fast-client call that merges the new + task's findings into the cumulative findings list. Duplicates get + folded; old findings that a new one supersedes get marked + `invalidated`. + +All inference happens over the Anthropic streaming API. Every +round-trip is logged to `.kres/logs//` so you can +inspect what each agent saw and replied. + +## Building up a larger review + +A single `--prompt 'review: fs/btrfs/ctree.c'` call seeds exactly +one task. That task's slow-agent response usually contains followup +suggestions like "investigate memory lifetime of the path argument" +or "check callers of btrfs_search_slot". The todo agent turns those +into todo items. + +From there: + +- **`/next`** runs the first pending todo item as its own task. +- **`/continue`** dispatches every pending todo item. +- **auto-continue**: when there are pending todos and no active + tasks, kres launches `/continue` automatically after 5 seconds of + idle. You can override the idle by typing anything, including + `/stop`. + +Each task feeds back into the same pipeline: fast → main → slow → +merger, plus the todo agent deduping any new followups against the +existing list. The goal agent (a special mode of the main-agent +model) periodically checks whether the original prompt has been +satisfied; if yes, work stops even if followups remain. + +A full review of a substantial source file usually takes between 5 +and 50 task runs, depending on how branchy the code is and how +aggressive the slow agent is about producing followup questions. +`--turns` caps that (see +[docs/turns-and-follow.md](turns-and-follow.md)); `/quit` lets you +bail out early and resume later with `--resume`. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..cb4fc62 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,58 @@ +# CLI and REPL commands + +## CLI + +``` +kres test [--prompt ...] [--model ...] +kres turn -o [-i ] [other flags] +kres [--fast-agent ...] [--slow TAG | --slow-agent ...] [--main-agent ...] + [--todo-agent ...] [--mcp-config ...] [--skills DIR] + [--results DIR] [--findings PATH] [--report PATH] [--todo PATH] + [--prompt PROMPT] [--template PATH] [--turns N] + [--follow] [--resume] + [--gather-turns N] [--stop-grace-ms MS] [--stdio] + [--allow ACTION]... [--summary] [--markdown] +``` + +Pass `kres --help` for the full list with argument-by-argument +descriptions. + +Related docs: + +- [turns-and-follow.md](turns-and-follow.md) — `--turns N`, + `--turns 0`, `--follow`, stagnation cap. +- [action-allowlist.md](action-allowlist.md) — `--allow ACTION` + and the dispatcher's non-MCP allowlist. +- [summary.md](summary.md) — `--summary`, `--template`, + `--markdown`. +- [configuration.md](configuration.md) — model-id overrides + (`--fast-model`, `--slow-model`, `--main-model`, + `--todo-model`). + +## REPL commands + +| Command | Action | +|--------------------------------|--------| +| `/help`, `/?` | Command list | +| `/tasks`, `/task` | Show active tasks and states | +| `/findings` | Summarise current findings list | +| `/stop` | Cancel running tasks (auto-continue pauses) | +| `/clear` | Cancel tasks, reset findings + todo + accumulated context | +| `/compact` | Replace accumulated context with short fast-agent summary | +| `/cost` | Print API token usage | +| `/todo` / `/todo --clear` | Show or clear the todo list | +| `/plan` | Show the current plan + per-step status | +| `/resume [PATH]` | Load a persisted `session.json` | +| `/followup` | List items deferred by goal-met or `--turns` cap | +| `/summary [FILE]` | Render `report.md` + `findings.json` to a plain-text bug report | +| `/summary-markdown [FILE]` | Same as `/summary`, markdown output | +| `/review ` | Compose the review template + target, submit | +| `/extract …` | Copy artifacts out (`--dir`, `--report`, `--todo`, `--findings`) | +| `/done N` | Remove the N'th pending todo | +| `/report ` | Write findings to markdown | +| `/load ` | Submit a file's contents as a prompt | +| `/edit` | Open `$EDITOR`, submit on save (also ctrl-g) | +| `/reply ` | Prepend last analysis to new text, submit | +| `/next` | Dispatch the next pending todo | +| `/continue` | Dispatch every unblocked pending todo | +| `/quit`, `/exit` | Leave the REPL | diff --git a/docs/coding-tasks.md b/docs/coding-tasks.md new file mode 100644 index 0000000..9ba1144 --- /dev/null +++ b/docs/coding-tasks.md @@ -0,0 +1,63 @@ +# Coding tasks — reproducers and in-place fixes + +Not every prompt is a review. Ask kres `--prompt 'write a +reproducer for the UAF in net/sched/cls_bpf.c'` or `--prompt 'fix +the missing frag-free in bnxt_xdp_redirect'` and the goal agent +classifies the task as **coding mode** instead of analysis. Coding +mode swaps out the review pipeline's lens fan-out and findings +consolidator for a single slow-agent call whose job is to produce +source code. Two output channels: + +- **`code_output`** — a list of `{path, content, purpose}` + records. Each entry is a full file body that the reaper writes + under `/code/` via tmp + rename. Use this for + fresh artifacts (reproducers, test harnesses, trigger programs, + scratch fixes that rewrite a whole file). + +- **`code_edits`** — a list of `{file_path, old_string, + new_string, replace_all}` records, same shape as Claude Code's + Edit primitive. The reaper applies each edit in order via + `kres_agents::tools::edit_file`: `old_string` must appear + exactly once in the current file contents (unless + `replace_all: true`), and the file is rewritten atomically via + tmp + rename (`kres-agents/src/tools.rs`). This is the + preferred channel for surgical one-line fixes — the + `old_string` anchor forces the slow agent to quote bytes from + the real file rather than reconstruct them from summary-level + descriptions. Each edit's result (replacement count for + success, verbatim error message for failure) is folded into + the task's analysis trailer under `Edits applied (N/M[, K + FAILED]):` so the next slow-agent turn can see which edits + landed and correct any that didn't. + +The slow-code prompt (`configs/prompts/slow-code-agent-coding.system.md`) +enforces two rules that matter in practice: the verbatim current +contents of the file being fixed must be in the gathered symbols +or context before any edit is emitted (a `read` followup is +requested and waited on otherwise — the slow agent is explicitly +told not to fix from memory), and a multi-edit batch applies in +emission order with each `old_string` matching the file state +AFTER prior edits in the same batch have landed. + +**Verification via `bash`** — the slow agent can emit a `bash` +followup (e.g. `cc -o repro repro.c && ./repro`, `make -C test`) +to build and run what it just wrote. The main agent executes it +from the workspace root, captures `[exit N]` + stdout + stderr, +and feeds the result back. This is the one flow where `bash` is +genuinely useful — but it is OFF by default (see +[action-allowlist.md](action-allowlist.md)) and must be explicitly +enabled for the session. + +On a coding run you typically invoke kres with: + +``` +kres --prompt 'write a reproducer for the stack OOB in x_tables' \ + --allow bash \ + --results repro-run +``` + +Artifacts land in `/code/` (for `code_output`) and +in-place under `` (for `code_edits`). The ordinary +`report.md` + `findings.json` ledger continues to accumulate +narrative; coding tasks skip the findings-merger path since their +output is source files, not bug records. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..dc24ef9 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,57 @@ +# Slash-command templates + +`review` / `summary` / `summary-markdown` are embedded +slash-command templates. Each has an `.md` body bundled in the +kres binary via `kres_agents::user_commands`, and an operator +can override or add commands by dropping a file at +`~/.kres/commands/.md`. + +Invocation paths (all three commands available in both places, +plus arbitrary operator commands dropped under +`~/.kres/commands/.md` are invocable the same way): + +| Command | CLI | REPL | +|--------------------|------------------------------------------------------------------------------------------|--------------------------------| +| `review` | `kres --prompt 'review: fs/btrfs/ctree.c'` or `kres --prompt '/review fs/btrfs/ctree.c'` | `/review fs/btrfs/ctree.c` | +| `summary` | `kres --summary --results DIR` | `/summary [filename]` | +| `summary-markdown` | `kres --summary --markdown --results DIR` | `/summary-markdown [filename]` | + +The `review:` and `/review` CLI forms compose the template body +with the trailing target; the `/review` REPL form does the same +composition through `user_commands::compose` and submits the +result as a new task. + +The shipped three: + +- `review` — the parallel-lens review template (see + [review-template.md](review-template.md)). Invocation prepends + the operator's target to the template body. +- `summary` — the plain-text bug-report system prompt that + `/summary` and `kres --summary` pass to the fast agent. +- `summary-markdown` — the markdown-output variant selected by + `--markdown`. + +Adding your own: drop `~/.kres/commands/audit.md` and run +`kres --prompt 'audit: net/...'` or `kres --prompt '/audit +net/...'`. No rebuild needed — the disk override path is +consulted on every invocation. + +Load order (identical for every command): + +1. `~/.kres/commands/.md` on disk (operator override). +2. Embedded body in `kres_agents::user_commands` (for the three + shipped commands). +3. Fallback to the legacy `~/.kres/prompts/-template.md` + lookup when neither of the above hit — preserves existing + custom templates from before this refactor. +4. Nothing matched → treat `"name: extra"` as a verbatim prompt. + +Files that setup.sh still copies to `~/.kres/prompts/`: any +operator-authored `-template.md` the user drops into +`configs/prompts/` that isn't shadowed by an embedded command +of the same root name. The shipped `review-template.md`, +`bug-summary.md`, and `bug-summary-markdown.md` are NOT copied +(they're embedded); `configs/prompts/-template.md` for +any other `` is copied verbatim so custom templates from +before the refactor keep working via the legacy +`~/.kres/prompts/-template.md` fallback path. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..8060f08 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,111 @@ +# Configuration — `~/.kres/` layout, models, and system prompts + +## Config directory: `~/.kres/` + +`kres repl` resolves every optional config path in this order: + +1. explicit CLI flag (e.g. `--fast-agent /path/to/fast.json`) +2. same filename under `~/.kres/` + +Default filenames looked up in `~/.kres/`: + +| Flag | Default under `~/.kres/` | +|-------------------|----------------------------------| +| `--fast-agent` | `fast-code-agent.json` | +| `--slow` tag | `slow-code-agent-.json` | +| `--main-agent` | `main-agent.json` | +| `--todo-agent` | `todo-agent.json` | +| `--mcp-config` | `mcp.json` | +| `--skills` | `skills/` | +| `--findings` | `findings.json` | + +A missing file in `~/.kres/` is not an error — the "not configured" +branch fires as if the flag were absent. + +The `history` file is always written to `~/.kres/history` regardless +of other flags; it holds readline line-edit history. + +## Model selection + +`~/.kres/settings.json` carries per-user default model ids per +agent role. `setup.sh --slow MODEL` / `--model MODEL` populate +the slow slot and the fast / main / todo slots respectively; +default values are `claude-opus-4-7` (slow) and +`claude-sonnet-4-6` (the rest). + +Model-id precedence at runtime (see +`kres-repl/src/settings.rs::pick_model`): + +1. The agent config's explicit `"model"` field when present. +2. The matching `settings.models.` string in + `~/.kres/settings.json`. +3. `Model::sonnet_4_6()` — the built-in fallback when both of + the above are absent. + +The shipped agent configs no longer set `"model"`, so in a +fresh install step 2 drives the actual choice. Reintroducing a +`"model"` line in one of the agent configs still takes effect +and overrides settings.json for that agent only. + +CLI overrides for a single run: `--fast-model`, `--slow-model`, +`--main-model`, `--todo-model` all beat `settings.json`. A +known `--slow ` (sonnet/opus) implies a slow model id too, +unless `--slow-model` is also passed. + +Running `--slow` and `--model` against the same model id is +fine and often what you want if you only have one model's +credentials. The difference between "fast" and "slow" work is +driven by the per-agent system prompts shipped under +`configs/prompts/` and the amount of context each agent +receives, not by the model choice — so pointing both at the +same id still produces the full fast/main/slow pipeline, each +agent thinking as hard or as lightly as its prompt asks. Using +two different models is an optimisation for cost or latency, +not a correctness requirement. + +## System prompts + +Agent `*.system.md` prompts (fast / slow / slow-coding / +slow-generic / main / todo) are compiled into the kres binary +via `include_str!` (see `kres-agents/src/embedded_prompts.rs`). +`setup.sh` does NOT install them on disk. Rebuilding kres +refreshes them. + +The shipped agent configs under `configs/*.json` reference +`system_file: "system-prompts/.system.md"`; the path is +resolved relative to the config file's directory, so at runtime +it becomes `~/.kres/system-prompts/.system.md`. + +Load order used by `AgentConfig::load`: + +1. **Disk override**: `~/.kres/system-prompts/`. If + this file exists and is non-empty it is used verbatim. +2. **Embedded**: the compiled-in copy keyed by basename. +3. **Error**: neither present → config load fails with a + message that names both paths. + +To customise an agent prompt for your own install, drop the +edited file at `~/.kres/system-prompts/`. The default +install has no files there; the embedded copies do all the work. + +Slash-command templates (`/review`, `/summary`, +`/summary-markdown`) live in a separate module +(`kres-agents/src/user_commands.rs`) with their own override +directory at `~/.kres/commands/`. See +[commands.md](commands.md). + +### Why distinct override directories? + +Older installs populated `~/.kres/prompts/` directly from +setup.sh (both `*.system.md` and `bug-summary*.md`). Keeping +the override in the same directory would mean those leftover +files shadow the embedded defaults and produce stale behaviour +after an upgrade. Two fresh directory names +(`~/.kres/system-prompts/` and `~/.kres/commands/`) sidestep +that — a fresh kres reads only the embedded defaults until the +operator deliberately drops a file under the new paths. Stale +files under `~/.kres/prompts/` are safe to delete (the +slash-command loader still reads `-template.md` from +there as a back-compat fallback, but will never find a +filename matching one of the shipped embedded commands there +since setup.sh never writes those names to `prompts/`). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..bf2ee34 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,55 @@ +# Development + +## Workspace layout + +``` +kres/ +├── Cargo.toml Rust workspace manifest +├── kres/ binary crate (`kres` command) +├── kres-core/ Task, TaskManager, Plan, shutdown, findings +├── kres-llm/ Anthropic streaming client + rate limiter +├── kres-mcp/ stdio JSON-RPC client for MCP servers +├── kres-agents/ fast / slow / main / todo / consolidator / merger +├── kres-repl/ readline UI, commands, signal handling +├── configs/ per-agent JSON configs (shipped defaults) +│ ├── fast-code-agent.json +│ ├── slow-code-agent-opus.json +│ ├── slow-code-agent-sonnet.json +│ ├── main-agent.json +│ ├── todo-agent.json +│ ├── settings.json +│ ├── mcp.json +│ └── prompts/ system prompts + review templates +├── skills/ domain-knowledge markdown fed to agents +│ └── kernel.md +├── docs/ JSON-schema docs + feature guides +├── CLAUDE.md project instructions for Claude Code +├── setup.sh bootstrap ~/.kres/ from configs/ +├── .githooks/pre-commit runs cargo fmt + clippy on every commit +├── NEWS.md +└── README.md +``` + +## Build, test, lint + +``` +cargo build --release +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all --check +``` + +## Pre-commit hook + +`.githooks/pre-commit` runs `cargo fmt --check` + `cargo clippy -D +warnings` on every commit. Enable it per-clone with: + +``` +git config core.hooksPath .githooks +``` + +## Wire-format references + +- [findings-json-format.md](findings-json-format.md) +- [prompt-json-format.md](prompt-json-format.md) +- [response-json-format.md](response-json-format.md) diff --git a/docs/review-prompts.md b/docs/review-prompts.md new file mode 100644 index 0000000..d2b2af8 --- /dev/null +++ b/docs/review-prompts.md @@ -0,0 +1,32 @@ +# Kernel review prompts + +kres can leverage the kernel review prompts for additional +subsystem knowledge. These live in a separate repo: + +https://github.com/masoncl/review-prompts + +The shipped kernel skill (`skills/kernel.md`) is a thin loader: +it references `@REVIEW_PROMPTS@/kernel/technical-patterns.md` as +a mandatory read on every slow-agent turn, plus +`@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` as an index into +per-subsystem guides. `setup.sh` substitutes `@REVIEW_PROMPTS@` +with an on-disk path at install time (see `skills/kernel.md:8`, +`skills/kernel.md:17`, `skills/kernel.md:29`). + +Point `setup.sh` at your clone so the skill can resolve those files: + +``` +./setup.sh --fast-key $FAST_API_KEY --slow-key $SLOW_API_KEY \ + --review-prompts /path/to/review-prompts +``` + +Without a resolvable path, `setup.sh` leaves the kernel skill +uninstalled (`setup.sh:386-389`) — the agents will still run, +but the slow agent won't have the pattern catalogue or subsystem +context, so findings tend to be shallower and miss conventions +that are obvious to someone who has read the pattern files. + +If a path wasn't given explicitly, `setup.sh` peeks at +`~/.claude/skills/kernel/SKILL.md` and offers the first +`review-prompts` path it finds there (`setup.sh:338-372`); pass +`--review-prompts PATH` explicitly to bypass the prompt. diff --git a/docs/review-template.md b/docs/review-template.md new file mode 100644 index 0000000..6b4f023 --- /dev/null +++ b/docs/review-template.md @@ -0,0 +1,83 @@ +# Review template — the `/review` parallel-lens flow + +`--prompt 'review: fs/btrfs/ctree.c'` is a two-part prompt: the +token `review` names the slash-command template embedded in the +kres binary (source: `configs/prompts/review-template.md`), and +the rest of the string is the specific target. kres splices the +target onto the front of the template body to produce a full +prompt covering object lifetime, memory safety, bounds checks, +races, and general bugs in the named code. + +Two equivalent forms — pick whichever reads better: + +``` +kres --prompt 'review: fs/btrfs/ctree.c' +kres --prompt '/review fs/btrfs/ctree.c' +``` + +Both resolve via `kres_agents::user_commands::lookup("review")`, +which prefers `~/.kres/commands/review.md` on disk (the operator +override path) and falls back to the embedded copy. Drop a file +at `~/.kres/commands/.md` to add a new command; use the +same `--prompt "name: extra"` or `--prompt "/name extra"` form +to invoke it. See [docs/commands.md](commands.md). + +Legacy compatibility: `--prompt "word: extra"` still falls back +to `~/.kres/prompts/-template.md` when no matching +`~/.kres/commands/.md` exists and the name isn't one of +the embedded commands — operators with custom `-template.md` +files from before the refactor keep working. + +The template is invoked only when `review` appears as the +colon-terminated leading word (`"review:..."`) or as the +slash-prefixed leading word followed by whitespace +(`"/review ..."`). Free-form text that happens to contain those +character sequences elsewhere (e.g. `"what caused the review: ..."`) +is submitted verbatim — the split is anchored to the start of +the prompt. + +## Parallel lenses inside `review-template.md` + +The shipped template is more than a prose prompt — each of its +markdown todo bullets is a **lens**: + +``` +- [ ] **[investigate]** object lifetime: #lifetime +- [ ] **[investigate]** memory allocations: #memory +- [ ] **[investigate]** bounds checks ... #bounds +- [ ] **[investigate]** races: #races +- [ ] **[investigate]** general: #general +``` +(`configs/prompts/review-template.md`) + +`kres_agents::parse_prompt_file` +(`kres-agents/src/prompt_file.rs:28-98`) turns each bullet into a +`LensSpec` (id, kind, name, reason) and installs them as +**session-wide lenses**. For every task, kres then fans out one +slow-agent call per lens over the *same* gathered symbols and +source sections — five parallel analyses in the case of the shipped +template — and runs a consolidator pass that dedupes the findings +across lenses before the merger folds them into the cumulative +list (`kres-core/src/lens.rs:1-7`). + +That parallelism is what makes a single `review:` run productive: +instead of the slow agent juggling lifetime + memory + bounds + +races + general bugs in one response, each angle gets its own +focused call with the full context, and overlap between findings +is resolved at consolidation time. Indented sub-bullets under a +lens bullet fold into its `reason` field and become extra guidance +the slow agent sees on that specific lens (see the sub-bullets +under `object lifetime` and `memory allocations` in the template). + +To add or remove angles for your own reviews, drop a customised +copy of the review template at `~/.kres/commands/review.md` — it +takes precedence over the embedded copy at load time. Dropping a +new `.md` alongside it (e.g. `~/.kres/commands/audit.md`) +adds a `/audit` slash-command you can invoke via +`--prompt "audit: target"` or `--prompt "/audit target"`. + +`--results ` tells kres where to keep the run's artifacts: +`findings.json` (plus `findings-N.json` history snapshots), the +running narrative `report.md`, and the rendered `bug-report.txt` +when `/summary` fires. Without `--results`, kres picks +`~/.kres/sessions//` automatically. diff --git a/docs/semcode.md b/docs/semcode.md new file mode 100644 index 0000000..ab082ae --- /dev/null +++ b/docs/semcode.md @@ -0,0 +1,58 @@ +# semcode MCP integration + +The main agent's code-navigation and searching can be enhanced +by semcode: + +https://github.com/facebookexperimental/semcode + +When a `semcode-mcp` binary is installed, `setup.sh` writes an +`mcp.json` that launches it as an MCP child: + +```json +{ + "mcpServers": { + "semcode": { "command": "semcode-mcp" } + } +} +``` + +(`configs/mcp.json`). + +kres works without semcode — the main agent can already answer +code questions with `read`, `grep`, and `git` against the +workspace (`CLAUDE.md:9,16`). When semcode is available, the +main agent gets a function/type/callchain-aware index to ask +instead of deriving the same information from raw regex. + +Tools semcode exposes that the main agent will call when wired up: + +- Function- and type-level lookups: `find_function`, `find_type`, + `find_callers`, `find_calls`, `find_callchain`, `grep_functions`. +- Commit- and branch-level helpers: `find_commit`, + `compare_branches`, `diff_functions`, `list_branches`. +- Vector-indexed search: `vgrep_functions`, + `vcommit_similar_commits`, `vlore_similar_emails`, `lore_search`. + +Raw semcode symbol text is normalised back into a uniform JSON +shape by `parse_semcode_symbol` (`kres-agents/src/symbol.rs:52-59`) +before reaching the fast/slow agents. + +## When it helps + +Whole-program questions that read/grep can only approximate — +"who calls `btrfs_search_slot`", "what does the definition of +`struct inode` look like on this branch", "show me every change +to this function in the last 1000 commits". Without semcode the +main agent still answers those, just via more grep round-trips +with more false positives. + +## Install + +Either drop `semcode-mcp` on your `PATH` before running +`setup.sh` (it auto-installs `mcp.json`, `setup.sh:265-269`) or +pass `--semcode PATH/TO/semcode-mcp` explicitly +(`setup.sh:41-45`). `--semcode ""` force-skips the MCP install +even when the binary is on `PATH`. kres's `.gitignore` excludes +a `/.semcode.db/` directory at the repo root (`.gitignore:4`) — +that's semcode's on-disk index cache; consult the semcode repo +for details on how it's populated and invalidated. diff --git a/docs/summary.md b/docs/summary.md new file mode 100644 index 0000000..a995940 --- /dev/null +++ b/docs/summary.md @@ -0,0 +1,28 @@ +# Summary output — `/summary`, `--summary`, and `bug-report.txt` + +After each task, kres appends the slow agent's narrative to +`/report.md` and rewrites `/findings.json` with +the cumulative merged list (the prior turn's canonical file is +copied to `findings-N.json` first, so you have the history). + +At the end of a run you get a plain-text bug report via `/summary` +(or automatically on `--turns` exit, or separately with +`kres --summary --results `). That run: + +- Picks up `/prompt.md` (saved on the first submit so + subsequent `/summary` or `--summary` invocations know the original + question), `/report.md`, and `/findings.json`. +- Uses the fast agent with the `summary` slash-command template + (embedded in the kres binary; overridable at + `~/.kres/commands/summary.md`) as a dedicated system prompt. + `--markdown` selects the `summary-markdown` variant instead. +- Orders the resulting sections by `bug-severity` — `high` → + `medium` → `low` → `latent` → `unknown` — with one section per + bug, each led by `Subject:`, `bug-severity:`, and `bug-impact:` + lines. +- Writes the result to `/bug-report.txt` (or + `bug-report.txt` in the current working directory if you did not + pass `--results`). + +You can point `--template PATH` at a custom file to override the +shipped summariser prompt without rebuilding. diff --git a/docs/turns-and-follow.md b/docs/turns-and-follow.md new file mode 100644 index 0000000..720d09f --- /dev/null +++ b/docs/turns-and-follow.md @@ -0,0 +1,46 @@ +# `--turns` and `--follow` — stopping the run + +`--turns` controls when kres decides a non-interactive run is "done". +A "completed task" throughout this page means a unit that ran all +the way through fast → main → slow and produced a non-empty analysis +(`kres-core/src/task.rs:309-311`). + +- **`--turns N` (N ≥ 1)** — stop after N completed tasks. Useful for + a single focused question (`--turns 1`) or a time-boxed review + (`--turns 5` etc.). The REPL exits as soon as the Nth task + finishes, regardless of what the goal agent or the followup queue + look like. `--follow` has no effect in this mode; the run-count + cap wins. + +- **`--turns 0` (the default)** — no run-count cap. kres trusts the + goal agent: after every task the goal agent checks the accumulated + analysis against the per-task goal; when it declares the goal met, + its handler drains the todo list and the reaper exits on the next + tick (nothing is active, nothing is pending). Until then kres + keeps dispatching the followup tasks the goal check spawns. + + - Add `--follow` to layer a cost cap on top: if 3 consecutive + analysis-producing runs fail to grow the findings list, exit + even if the goal agent is still saying "not met". Use this when + you want a hard ceiling on how long kres will keep pulling on + threads. + + (`kres-repl/src/session.rs` — see the `turns_limit == 0` branch in + the reaper for the exact predicates. If you run without a + `main-agent.json`, no goal agent is wired up and kres falls back + to "stop when the active batch finishes"; `--follow` switches that + fallback to "drain the todo list with the 3-run stagnation cap".) + +On any `--turns` exit path — run-count cap, goal-met drain, or +stagnation cap — kres + +1. cancels any in-flight work, +2. runs `/summary` automatically, producing `bug-report.txt` + (`bug-report.md` with `--markdown`) in the results directory, or + in the current working directory when `--results` was not given, + and +3. exits. + +Remaining pending or blocked todo items are moved to the "deferred" +list; `/followup` shows them if you re-enter the REPL later, and +`/continue` will dispatch them. From 472a58f533e6012c1c3c9d37a4d6286ff2f3acc9 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 11:06:53 -0700 Subject: [PATCH 29/76] docs: tighten topic guides and fix drifted line citations The topic guides split out of README.md carried over padding from the original long-form README, and several file:line citations had drifted against the current source (setup.sh line numbers in review-prompts.md and semcode.md, a task.rs range in turns-and-follow.md, a symbol.rs range in semcode.md). Prose across action-allowlist, agents, coding-tasks, commands, configuration, review-prompts, review-template, semcode, summary, and turns-and-follow compressed to fact-per-paragraph; stale numeric ranges either updated or dropped in favour of function/symbol names that do not rot. No content dropped - every fact reachable from the longer form is still there. Signed-off-by: Chris Mason --- docs/action-allowlist.md | 92 +++++++++++--------------------- docs/agents.md | 103 +++++++++++++++-------------------- docs/coding-tasks.md | 94 ++++++++++++++------------------ docs/commands.md | 53 +++++++----------- docs/configuration.md | 112 ++++++++++++++------------------------- docs/review-prompts.md | 33 +++++------- docs/review-template.md | 101 +++++++++++++---------------------- docs/semcode.md | 65 +++++++++++------------ docs/summary.md | 40 +++++++------- docs/turns-and-follow.md | 66 +++++++++-------------- 10 files changed, 301 insertions(+), 458 deletions(-) diff --git a/docs/action-allowlist.md b/docs/action-allowlist.md index 3b08721..1d4a601 100644 --- a/docs/action-allowlist.md +++ b/docs/action-allowlist.md @@ -2,78 +2,48 @@ The main agent's non-MCP tools are gated by a session-wide allowlist. Defaults: `grep`, `find`, `read`, `git`, `edit`. -`bash` is **OFF by default** because operators report it being -reached for as a general escape hatch for things the typed tools -already cover (`bash sed` for range reads, `bash find` for -filename locates). An action whose `type` isn't in the allowlist -is rejected at dispatch time with a message naming the allowed -set and pointing at the two ways to fix it. +`bash` is OFF by default — operators reach for it as a generic +escape hatch for things the typed tools already cover +(`bash sed` for range reads, `bash find` for filename locates). +A disallowed action is rejected at dispatch time with a message +naming the allowed set. -## Three precedence levels +## Precedence -1. `--allow ACTION` CLI flags — additive on top of whatever the - files resolved to. Repeatable (`--allow bash --allow git`) or - comma-separated (`--allow bash,git`). The special value - `--allow all` enables every action type the dispatcher knows. -2. Per-project `/.kres/settings.json` — overrides global - values field-by-field; an explicit allowlist replaces rather - than unions with the global one. -3. Global `~/.kres/settings.json` — the default resting place - for a per-user policy. +1. `--allow ACTION` CLI flags — additive on top of file config. + Repeatable (`--allow bash --allow git`) or comma-separated + (`--allow bash,git`). `--allow all` enables every known + action. +2. `/.kres/settings.json` — per-project overrides; an + explicit allowlist replaces rather than unions with the + global one. +3. `~/.kres/settings.json` — per-user default. -## Examples +## Configuring via settings.json -**Enable bash for this session only:** - -``` -kres --allow bash --prompt 'reproduce the RDS UAF' -``` - -**Enable bash permanently in settings.json:** +Enable bash permanently: ```json -{ - "actions": { - "allowed": ["grep", "find", "read", "git", "edit", "bash"] - } -} +{ "actions": { "allowed": ["grep", "find", "read", "git", "edit", "bash"] } } ``` -**Deny every non-MCP action (tight lockdown, leaves only MCP -tools available to the main agent):** +Lock down to MCP-only: ```json -{ - "actions": { - "allowed": [] - } -} +{ "actions": { "allowed": [] } } ``` -The empty array is the explicit "lock it down" signal — kres -dispatcher enforces it and does not fall back to defaults. -A missing or absent `actions.allowed` (i.e. `null` or the key -unset) is different: it means "use the built-in default list". - -## Typo detection - -Tokens in `--allow` or `actions.allowed` that aren't recognised -action names produce a startup warning with a closest-match -suggestion (Levenshtein ≤ 2), e.g. `settings: unknown action -token 'bsah' (--allow) — did you mean 'bash'? known: grep, -find, read, git, edit, bash, mcp`. Unknown tokens are dropped -rather than silently inserted, so a typo never leaves a dead -entry in the allowlist. - -## Startup banner +The empty array is the explicit lock-it-down signal; a missing +or `null` `actions.allowed` falls back to the built-in default. -When a main-agent config is resolved, kres prints the effective -allowlist on startup and distinguishes "bash disabled by default" -from "bash disabled by explicit allowlist in settings.json". -Both point at `--allow bash` as the fix but the wording respects -the source of the decision. +## Behaviour -MCP tools are gated separately (by mcp.json server registration, -not this allowlist) and don't enter the allowlist's dispatch -path. `--allow mcp` is a no-op and does not produce a typo -warning. +- Typo detection: unknown tokens in `--allow` or + `actions.allowed` print a Levenshtein-≤2 suggestion + (`… 'bsah' — did you mean 'bash'?`) and are dropped, not + silently inserted. +- Startup banner: kres prints the effective allowlist and + distinguishes "bash disabled by default" from "disabled by + explicit allowlist". +- MCP tools are gated separately (by `mcp.json` registration). + `--allow mcp` is a no-op. diff --git a/docs/agents.md b/docs/agents.md index dbcd592..aec3802 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,66 +1,47 @@ -# Agents — flow of work between the fast, main, slow, and todo agents - -A task goes through three agents, all configured from `~/.kres/`: - -- **fast** (`fast-code-agent.json`): scopes the task, figures out - what source kres needs to look at, and emits a structured brief. - When it's ready it returns a list of "followups" — concrete fetch - requests (grep, file read, semcode symbol/callchain, git log) - that the main agent should run. - -- **main** (`main-agent.json`): the data fetcher. It takes the - fast agent's followups and dispatches them to local tools and to - any MCP servers configured in `mcp.json` (semcode in particular). - The output is funnelled back into the fast agent for another - round. This fast↔main loop runs until the fast agent says - `ready_for_slow`, or the `--gather-turns` cap is reached. - -- **slow** (`slow-code-agent-.json`, default `sonnet`): the - deep analyser. It receives the gathered symbols and file sections, - the cumulative findings from earlier tasks, and the task brief, - then produces a new analysis and any new findings. Slow-agent - output is cheap prose plus structured findings records. - -- **todo** (`todo-agent.json`): after the slow agent returns, the - todo agent dedups its followup suggestions against the existing - pending/done todo list and emits an updated list. This is what - drives larger reviews — see below. - -- **merger**: a non-agent fast-client call that merges the new - task's findings into the cumulative findings list. Duplicates get - folded; old findings that a new one supersedes get marked +# Agents — flow of work per task + +Every task cycles through these roles, all configured under +`~/.kres/`: + +- **fast** (`fast-code-agent.json`) — scopes the task and emits a + list of `followups`: grep / read / semcode / git fetches the + main agent should run. +- **main** (`main-agent.json`) — the data fetcher. Dispatches + followups to local tools and MCP servers (semcode via + `mcp.json`). Output is fed back into fast for another round. + The fast↔main loop ends when fast emits `ready_for_slow` or + `--gather-turns` is hit. +- **slow** (`slow-code-agent-.json`, default `sonnet`) — the + deep analyser. Gets the gathered symbols, the cumulative + findings, and the task brief; returns analysis prose plus + structured findings. +- **todo** (`todo-agent.json`) — dedups the slow agent's + followups against the current todo list, reprioritises, and + may reshape the plan (see [planning.md](planning.md) — TBD). +- **merger** — non-agent fast-client call that folds new + findings into the cumulative list; supersedes become `invalidated`. -All inference happens over the Anthropic streaming API. Every -round-trip is logged to `.kres/logs//` so you can -inspect what each agent saw and replied. +Every round-trip is logged to `.kres/logs//`. ## Building up a larger review -A single `--prompt 'review: fs/btrfs/ctree.c'` call seeds exactly -one task. That task's slow-agent response usually contains followup -suggestions like "investigate memory lifetime of the path argument" -or "check callers of btrfs_search_slot". The todo agent turns those -into todo items. - -From there: - -- **`/next`** runs the first pending todo item as its own task. -- **`/continue`** dispatches every pending todo item. -- **auto-continue**: when there are pending todos and no active - tasks, kres launches `/continue` automatically after 5 seconds of - idle. You can override the idle by typing anything, including - `/stop`. - -Each task feeds back into the same pipeline: fast → main → slow → -merger, plus the todo agent deduping any new followups against the -existing list. The goal agent (a special mode of the main-agent -model) periodically checks whether the original prompt has been -satisfied; if yes, work stops even if followups remain. - -A full review of a substantial source file usually takes between 5 -and 50 task runs, depending on how branchy the code is and how -aggressive the slow agent is about producing followup questions. -`--turns` caps that (see -[docs/turns-and-follow.md](turns-and-follow.md)); `/quit` lets you -bail out early and resume later with `--resume`. +One `--prompt 'review: fs/btrfs/ctree.c'` seeds one task. Its +slow-agent response usually emits followup suggestions the todo +agent converts into todo items. To work through them: + +- `/next` runs the first pending item. +- `/continue` dispatches every unblocked pending item. +- auto-continue fires `/continue` after 5s idle when there are + pending todos and no active tasks. Typing (including `/stop`) + cancels the idle. + +The goal agent (a judge-mode call on the main-agent client) +checks after every task whether the original prompt is +satisfied; goal-met stops work even with pending followups. + +A thorough review of a real source file runs 5–50 tasks +depending on branchiness and how aggressive the slow agent is +about follow-up questions. `--turns` bounds it +(see [turns-and-follow.md](turns-and-follow.md)); `/quit` bails +out and `--resume` picks up later. diff --git a/docs/coding-tasks.md b/docs/coding-tasks.md index 9ba1144..5a59235 100644 --- a/docs/coding-tasks.md +++ b/docs/coding-tasks.md @@ -1,54 +1,42 @@ # Coding tasks — reproducers and in-place fixes -Not every prompt is a review. Ask kres `--prompt 'write a -reproducer for the UAF in net/sched/cls_bpf.c'` or `--prompt 'fix -the missing frag-free in bnxt_xdp_redirect'` and the goal agent -classifies the task as **coding mode** instead of analysis. Coding -mode swaps out the review pipeline's lens fan-out and findings -consolidator for a single slow-agent call whose job is to produce -source code. Two output channels: - -- **`code_output`** — a list of `{path, content, purpose}` - records. Each entry is a full file body that the reaper writes - under `/code/` via tmp + rename. Use this for - fresh artifacts (reproducers, test harnesses, trigger programs, - scratch fixes that rewrite a whole file). - -- **`code_edits`** — a list of `{file_path, old_string, - new_string, replace_all}` records, same shape as Claude Code's - Edit primitive. The reaper applies each edit in order via - `kres_agents::tools::edit_file`: `old_string` must appear - exactly once in the current file contents (unless - `replace_all: true`), and the file is rewritten atomically via - tmp + rename (`kres-agents/src/tools.rs`). This is the - preferred channel for surgical one-line fixes — the - `old_string` anchor forces the slow agent to quote bytes from - the real file rather than reconstruct them from summary-level - descriptions. Each edit's result (replacement count for - success, verbatim error message for failure) is folded into - the task's analysis trailer under `Edits applied (N/M[, K - FAILED]):` so the next slow-agent turn can see which edits - landed and correct any that didn't. - -The slow-code prompt (`configs/prompts/slow-code-agent-coding.system.md`) -enforces two rules that matter in practice: the verbatim current -contents of the file being fixed must be in the gathered symbols -or context before any edit is emitted (a `read` followup is -requested and waited on otherwise — the slow agent is explicitly -told not to fix from memory), and a multi-edit batch applies in -emission order with each `old_string` matching the file state -AFTER prior edits in the same batch have landed. - -**Verification via `bash`** — the slow agent can emit a `bash` -followup (e.g. `cc -o repro repro.c && ./repro`, `make -C test`) -to build and run what it just wrote. The main agent executes it -from the workspace root, captures `[exit N]` + stdout + stderr, -and feeds the result back. This is the one flow where `bash` is -genuinely useful — but it is OFF by default (see -[action-allowlist.md](action-allowlist.md)) and must be explicitly -enabled for the session. - -On a coding run you typically invoke kres with: +Prompts like `--prompt 'write a reproducer for the UAF in +net/sched/cls_bpf.c'` or `--prompt 'fix the missing frag-free +in bnxt_xdp_redirect'` get classified as **coding mode** by the +goal agent. Coding mode replaces the lens fan-out and findings +consolidator with a single slow-agent call producing source +code on two channels: + +- **`code_output`** — `{path, content, purpose}` records. The + reaper writes each entry to `/code/` via + tmp + rename. Use for fresh artifacts (reproducers, test + harnesses, trigger programs, whole-file fixes). + +- **`code_edits`** — `{file_path, old_string, new_string, + replace_all}` records (same shape as Claude Code's Edit + primitive). `old_string` must match exactly once unless + `replace_all: true`; the file is rewritten atomically via + `kres_agents::tools::edit_file` (tmp + rename). Preferred for + surgical fixes: the `old_string` anchor forces the agent to + quote real bytes rather than reconstruct from memory. Per-edit + results fold into the analysis trailer as + `Edits applied (N/M[, K FAILED]):` so the next slow turn sees + which edits landed. + +The slow-code prompt +(`configs/prompts/slow-code-agent-coding.system.md`) enforces two +rules worth knowing: the verbatim file contents must be in +`symbols` or `context` before an edit is emitted (otherwise the +agent must issue a `read` followup and wait); and within one +batch each `old_string` matches the file state AFTER prior edits +in the same batch have landed. + +**Bash verification** — the slow agent can emit a `bash` followup +(`cc -o repro repro.c && ./repro`, `make -C test`, …) to build +and run what it wrote. The main agent executes from the workspace +root and feeds back `[exit N]` + stdout + stderr. `bash` is OFF +by default; see [action-allowlist.md](action-allowlist.md). A +typical invocation: ``` kres --prompt 'write a reproducer for the stack OOB in x_tables' \ @@ -56,8 +44,6 @@ kres --prompt 'write a reproducer for the stack OOB in x_tables' \ --results repro-run ``` -Artifacts land in `/code/` (for `code_output`) and -in-place under `` (for `code_edits`). The ordinary -`report.md` + `findings.json` ledger continues to accumulate -narrative; coding tasks skip the findings-merger path since their -output is source files, not bug records. +Artifacts land in `/code/` (code_output) and +in-place under `` (code_edits). Coding tasks skip the +findings merger — their output is source, not bug records. diff --git a/docs/commands.md b/docs/commands.md index dc24ef9..42c3317 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,56 +2,43 @@ `review` / `summary` / `summary-markdown` are embedded slash-command templates. Each has an `.md` body bundled in the -kres binary via `kres_agents::user_commands`, and an operator -can override or add commands by dropping a file at +kres binary via `kres_agents::user_commands`, and operators can +override or add commands by dropping a file at `~/.kres/commands/.md`. -Invocation paths (all three commands available in both places, -plus arbitrary operator commands dropped under -`~/.kres/commands/.md` are invocable the same way): - | Command | CLI | REPL | |--------------------|------------------------------------------------------------------------------------------|--------------------------------| | `review` | `kres --prompt 'review: fs/btrfs/ctree.c'` or `kres --prompt '/review fs/btrfs/ctree.c'` | `/review fs/btrfs/ctree.c` | | `summary` | `kres --summary --results DIR` | `/summary [filename]` | | `summary-markdown` | `kres --summary --markdown --results DIR` | `/summary-markdown [filename]` | -The `review:` and `/review` CLI forms compose the template body -with the trailing target; the `/review` REPL form does the same -composition through `user_commands::compose` and submits the +Both CLI and REPL forms compose the template body with the +trailing target via `user_commands::compose` and submit the result as a new task. The shipped three: -- `review` — the parallel-lens review template (see - [review-template.md](review-template.md)). Invocation prepends +- `review` — parallel-lens review template (see + [review-template.md](review-template.md)); invocation prepends the operator's target to the template body. -- `summary` — the plain-text bug-report system prompt that - `/summary` and `kres --summary` pass to the fast agent. -- `summary-markdown` — the markdown-output variant selected by - `--markdown`. +- `summary` — plain-text bug-report system prompt passed to the + fast agent by `/summary` and `kres --summary`. +- `summary-markdown` — markdown variant selected by `--markdown`. Adding your own: drop `~/.kres/commands/audit.md` and run -`kres --prompt 'audit: net/...'` or `kres --prompt '/audit -net/...'`. No rebuild needed — the disk override path is -consulted on every invocation. +`kres --prompt 'audit: net/...'` or `/audit net/...`. No rebuild +needed — the disk override path is consulted on every invocation. Load order (identical for every command): 1. `~/.kres/commands/.md` on disk (operator override). -2. Embedded body in `kres_agents::user_commands` (for the three - shipped commands). -3. Fallback to the legacy `~/.kres/prompts/-template.md` - lookup when neither of the above hit — preserves existing +2. Embedded body in `kres_agents::user_commands` (shipped three). +3. Legacy `~/.kres/prompts/-template.md` — back-compat for custom templates from before this refactor. -4. Nothing matched → treat `"name: extra"` as a verbatim prompt. - -Files that setup.sh still copies to `~/.kres/prompts/`: any -operator-authored `-template.md` the user drops into -`configs/prompts/` that isn't shadowed by an embedded command -of the same root name. The shipped `review-template.md`, -`bug-summary.md`, and `bug-summary-markdown.md` are NOT copied -(they're embedded); `configs/prompts/-template.md` for -any other `` is copied verbatim so custom templates from -before the refactor keep working via the legacy -`~/.kres/prompts/-template.md` fallback path. +4. No match → treat `"name: extra"` as a verbatim prompt. + +`setup.sh` still copies operator-authored +`configs/prompts/-template.md` to `~/.kres/prompts/` for +any `` that isn't one of the embedded names, so +pre-refactor custom templates keep working through the legacy +fallback. diff --git a/docs/configuration.md b/docs/configuration.md index 8060f08..08252e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,93 +19,63 @@ Default filenames looked up in `~/.kres/`: | `--skills` | `skills/` | | `--findings` | `findings.json` | -A missing file in `~/.kres/` is not an error — the "not configured" -branch fires as if the flag were absent. - -The `history` file is always written to `~/.kres/history` regardless -of other flags; it holds readline line-edit history. +A missing file in `~/.kres/` is not an error — the "not +configured" branch fires as if the flag were absent. The +`history` file is always written to `~/.kres/history`. ## Model selection `~/.kres/settings.json` carries per-user default model ids per agent role. `setup.sh --slow MODEL` / `--model MODEL` populate the slow slot and the fast / main / todo slots respectively; -default values are `claude-opus-4-7` (slow) and -`claude-sonnet-4-6` (the rest). - -Model-id precedence at runtime (see -`kres-repl/src/settings.rs::pick_model`): - -1. The agent config's explicit `"model"` field when present. -2. The matching `settings.models.` string in - `~/.kres/settings.json`. -3. `Model::sonnet_4_6()` — the built-in fallback when both of - the above are absent. - -The shipped agent configs no longer set `"model"`, so in a -fresh install step 2 drives the actual choice. Reintroducing a -`"model"` line in one of the agent configs still takes effect -and overrides settings.json for that agent only. - -CLI overrides for a single run: `--fast-model`, `--slow-model`, -`--main-model`, `--todo-model` all beat `settings.json`. A -known `--slow ` (sonnet/opus) implies a slow model id too, -unless `--slow-model` is also passed. - -Running `--slow` and `--model` against the same model id is -fine and often what you want if you only have one model's -credentials. The difference between "fast" and "slow" work is -driven by the per-agent system prompts shipped under -`configs/prompts/` and the amount of context each agent -receives, not by the model choice — so pointing both at the -same id still produces the full fast/main/slow pipeline, each -agent thinking as hard or as lightly as its prompt asks. Using -two different models is an optimisation for cost or latency, -not a correctness requirement. +defaults are `claude-opus-4-7` (slow) and `claude-sonnet-4-6` +(the rest). + +Runtime precedence (`kres-repl/src/settings.rs::pick_model`): + +1. The agent config's explicit `"model"` field. +2. `settings.models.` in `~/.kres/settings.json`. +3. `Model::sonnet_4_6()` — built-in fallback. + +Shipped agent configs no longer set `"model"`, so step 2 drives +a fresh install. Per-run CLI overrides (`--fast-model`, +`--slow-model`, `--main-model`, `--todo-model`) beat +`settings.json`. A known `--slow ` (sonnet/opus) implies a +slow model id unless `--slow-model` is also passed. + +Pointing fast and slow at the same model is fine: the fast/slow +distinction is driven by per-agent system prompts and the +context each agent receives, not by model choice. Two different +models is a cost/latency optimisation, not a correctness +requirement. ## System prompts Agent `*.system.md` prompts (fast / slow / slow-coding / slow-generic / main / todo) are compiled into the kres binary -via `include_str!` (see `kres-agents/src/embedded_prompts.rs`). -`setup.sh` does NOT install them on disk. Rebuilding kres -refreshes them. +(`kres-agents/src/embedded_prompts.rs`). `setup.sh` does NOT +install them on disk — rebuilding kres refreshes them. -The shipped agent configs under `configs/*.json` reference -`system_file: "system-prompts/.system.md"`; the path is -resolved relative to the config file's directory, so at runtime -it becomes `~/.kres/system-prompts/.system.md`. +Shipped configs reference `system_file: +"system-prompts/.system.md"` resolved relative to the +config file's directory, i.e. `~/.kres/system-prompts/`. -Load order used by `AgentConfig::load`: +`AgentConfig::load` order: -1. **Disk override**: `~/.kres/system-prompts/`. If - this file exists and is non-empty it is used verbatim. -2. **Embedded**: the compiled-in copy keyed by basename. -3. **Error**: neither present → config load fails with a - message that names both paths. +1. **Disk override**: `~/.kres/system-prompts/` if it + exists and is non-empty — used verbatim. +2. **Embedded**: compiled-in copy keyed by basename. +3. **Error**: neither present → load fails naming both paths. -To customise an agent prompt for your own install, drop the -edited file at `~/.kres/system-prompts/`. The default -install has no files there; the embedded copies do all the work. +To customise, drop the edited file at +`~/.kres/system-prompts/`. A default install has no +files there; the embedded copies do all the work. Slash-command templates (`/review`, `/summary`, `/summary-markdown`) live in a separate module (`kres-agents/src/user_commands.rs`) with their own override -directory at `~/.kres/commands/`. See -[commands.md](commands.md). - -### Why distinct override directories? - -Older installs populated `~/.kres/prompts/` directly from -setup.sh (both `*.system.md` and `bug-summary*.md`). Keeping -the override in the same directory would mean those leftover -files shadow the embedded defaults and produce stale behaviour -after an upgrade. Two fresh directory names -(`~/.kres/system-prompts/` and `~/.kres/commands/`) sidestep -that — a fresh kres reads only the embedded defaults until the -operator deliberately drops a file under the new paths. Stale -files under `~/.kres/prompts/` are safe to delete (the -slash-command loader still reads `-template.md` from -there as a back-compat fallback, but will never find a -filename matching one of the shipped embedded commands there -since setup.sh never writes those names to `prompts/`). +directory at `~/.kres/commands/` — see +[commands.md](commands.md). The two directories are distinct so +that leftover files from older installs under +`~/.kres/prompts/` never shadow the embedded defaults; stale +files there are safe to delete. diff --git a/docs/review-prompts.md b/docs/review-prompts.md index d2b2af8..31fb891 100644 --- a/docs/review-prompts.md +++ b/docs/review-prompts.md @@ -1,19 +1,16 @@ # Kernel review prompts -kres can leverage the kernel review prompts for additional -subsystem knowledge. These live in a separate repo: +Subsystem knowledge for the kernel lives in a separate repo: +. -https://github.com/masoncl/review-prompts +`skills/kernel.md` is a thin loader that references +`@REVIEW_PROMPTS@/kernel/technical-patterns.md` as a mandatory +read on every slow-agent turn, plus +`@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` as the index +into per-subsystem guides. `setup.sh` substitutes +`@REVIEW_PROMPTS@` with an on-disk path at install time. -The shipped kernel skill (`skills/kernel.md`) is a thin loader: -it references `@REVIEW_PROMPTS@/kernel/technical-patterns.md` as -a mandatory read on every slow-agent turn, plus -`@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` as an index into -per-subsystem guides. `setup.sh` substitutes `@REVIEW_PROMPTS@` -with an on-disk path at install time (see `skills/kernel.md:8`, -`skills/kernel.md:17`, `skills/kernel.md:29`). - -Point `setup.sh` at your clone so the skill can resolve those files: +Point `setup.sh` at your clone: ``` ./setup.sh --fast-key $FAST_API_KEY --slow-key $SLOW_API_KEY \ @@ -21,12 +18,10 @@ Point `setup.sh` at your clone so the skill can resolve those files: ``` Without a resolvable path, `setup.sh` leaves the kernel skill -uninstalled (`setup.sh:386-389`) — the agents will still run, -but the slow agent won't have the pattern catalogue or subsystem -context, so findings tend to be shallower and miss conventions -that are obvious to someone who has read the pattern files. +uninstalled — agents still run, but the slow agent loses the +pattern catalogue and subsystem context. -If a path wasn't given explicitly, `setup.sh` peeks at +When `--review-prompts` is omitted, `setup.sh` peeks at `~/.claude/skills/kernel/SKILL.md` and offers the first -`review-prompts` path it finds there (`setup.sh:338-372`); pass -`--review-prompts PATH` explicitly to bypass the prompt. +review-prompts path it finds there. Pass `--review-prompts PATH` +to bypass the interactive prompt. diff --git a/docs/review-template.md b/docs/review-template.md index 6b4f023..21c0b48 100644 --- a/docs/review-template.md +++ b/docs/review-template.md @@ -1,45 +1,29 @@ # Review template — the `/review` parallel-lens flow -`--prompt 'review: fs/btrfs/ctree.c'` is a two-part prompt: the -token `review` names the slash-command template embedded in the -kres binary (source: `configs/prompts/review-template.md`), and -the rest of the string is the specific target. kres splices the -target onto the front of the template body to produce a full -prompt covering object lifetime, memory safety, bounds checks, -races, and general bugs in the named code. - -Two equivalent forms — pick whichever reads better: +`--prompt 'review: fs/btrfs/ctree.c'` splices the target onto +the front of the embedded review template +(`configs/prompts/review-template.md`), producing a prompt that +covers object lifetime, memory, bounds, races, and general +bugs. Two equivalent invocations: ``` kres --prompt 'review: fs/btrfs/ctree.c' kres --prompt '/review fs/btrfs/ctree.c' ``` -Both resolve via `kres_agents::user_commands::lookup("review")`, -which prefers `~/.kres/commands/review.md` on disk (the operator -override path) and falls back to the embedded copy. Drop a file -at `~/.kres/commands/.md` to add a new command; use the -same `--prompt "name: extra"` or `--prompt "/name extra"` form -to invoke it. See [docs/commands.md](commands.md). - -Legacy compatibility: `--prompt "word: extra"` still falls back -to `~/.kres/prompts/-template.md` when no matching -`~/.kres/commands/.md` exists and the name isn't one of -the embedded commands — operators with custom `-template.md` -files from before the refactor keep working. +Both resolve through `kres_agents::user_commands::lookup`: the +on-disk override `~/.kres/commands/review.md` wins over the +embedded copy. Drop `~/.kres/commands/.md` to add a new +command and invoke it via `"name: extra"` or `"/name extra"` — +see [commands.md](commands.md). -The template is invoked only when `review` appears as the -colon-terminated leading word (`"review:..."`) or as the -slash-prefixed leading word followed by whitespace -(`"/review ..."`). Free-form text that happens to contain those -character sequences elsewhere (e.g. `"what caused the review: ..."`) -is submitted verbatim — the split is anchored to the start of -the prompt. +The split is anchored at the start of the prompt. Free-form +text that contains `review:` or `/review` mid-string is +submitted verbatim. -## Parallel lenses inside `review-template.md` +## Parallel lenses -The shipped template is more than a prose prompt — each of its -markdown todo bullets is a **lens**: +Each markdown todo bullet in the template is a **lens**: ``` - [ ] **[investigate]** object lifetime: #lifetime @@ -48,36 +32,27 @@ markdown todo bullets is a **lens**: - [ ] **[investigate]** races: #races - [ ] **[investigate]** general: #general ``` -(`configs/prompts/review-template.md`) - -`kres_agents::parse_prompt_file` -(`kres-agents/src/prompt_file.rs:28-98`) turns each bullet into a -`LensSpec` (id, kind, name, reason) and installs them as -**session-wide lenses**. For every task, kres then fans out one -slow-agent call per lens over the *same* gathered symbols and -source sections — five parallel analyses in the case of the shipped -template — and runs a consolidator pass that dedupes the findings -across lenses before the merger folds them into the cumulative -list (`kres-core/src/lens.rs:1-7`). - -That parallelism is what makes a single `review:` run productive: -instead of the slow agent juggling lifetime + memory + bounds + -races + general bugs in one response, each angle gets its own -focused call with the full context, and overlap between findings -is resolved at consolidation time. Indented sub-bullets under a -lens bullet fold into its `reason` field and become extra guidance -the slow agent sees on that specific lens (see the sub-bullets -under `object lifetime` and `memory allocations` in the template). - -To add or remove angles for your own reviews, drop a customised -copy of the review template at `~/.kres/commands/review.md` — it -takes precedence over the embedded copy at load time. Dropping a -new `.md` alongside it (e.g. `~/.kres/commands/audit.md`) -adds a `/audit` slash-command you can invoke via -`--prompt "audit: target"` or `--prompt "/audit target"`. -`--results ` tells kres where to keep the run's artifacts: -`findings.json` (plus `findings-N.json` history snapshots), the -running narrative `report.md`, and the rendered `bug-report.txt` -when `/summary` fires. Without `--results`, kres picks -`~/.kres/sessions//` automatically. +`kres_agents::prompt_file::parse` turns each bullet into a +`LensSpec` and installs them as session-wide lenses. Every task +fans out one slow-agent call per lens over the same gathered +symbols and context; a consolidator dedupes the findings across +lenses before the merger folds them into the cumulative list +(`kres-core/src/lens.rs`). That parallelism is the point — each +angle gets a focused call with full context, and overlap is +resolved at consolidation time. + +Indented sub-bullets under a lens bullet fold into its `reason` +field as extra guidance for that lens's slow-agent call (see the +`object lifetime` and `memory allocations` bullets in the shipped +template). + +To change the lens set, drop a customised copy at +`~/.kres/commands/review.md`. Dropping `~/.kres/commands/.md` +adds a `/` slash-command invocable via +`--prompt ": target"` or `--prompt "/ target"`. + +`--results ` keeps the run's artifacts (`findings.json` +plus `findings-N.json` history, `report.md`, `bug-report.txt`) +in `/`; without it kres picks +`~/.kres/sessions//`. diff --git a/docs/semcode.md b/docs/semcode.md index ab082ae..ff1e723 100644 --- a/docs/semcode.md +++ b/docs/semcode.md @@ -1,11 +1,8 @@ # semcode MCP integration -The main agent's code-navigation and searching can be enhanced -by semcode: - -https://github.com/facebookexperimental/semcode - -When a `semcode-mcp` binary is installed, `setup.sh` writes an +The main agent's code navigation is enhanced by semcode +(). When a +`semcode-mcp` binary is on `PATH`, `setup.sh` writes an `mcp.json` that launches it as an MCP child: ```json @@ -16,43 +13,41 @@ When a `semcode-mcp` binary is installed, `setup.sh` writes an } ``` -(`configs/mcp.json`). +kres works without semcode — the main agent already answers +code questions with `read`, `grep`, and `git`. semcode adds a +function/type/callchain-aware index so the agent can ask +whole-program questions directly instead of deriving them from +raw regex. -kres works without semcode — the main agent can already answer -code questions with `read`, `grep`, and `git` against the -workspace (`CLAUDE.md:9,16`). When semcode is available, the -main agent gets a function/type/callchain-aware index to ask -instead of deriving the same information from raw regex. +Tools the main agent will call when wired up: -Tools semcode exposes that the main agent will call when wired up: +- Symbols: `find_function`, `find_type`, `find_callers`, + `find_calls`, `find_callchain`, `grep_functions`. +- Commits / branches: `find_commit`, `compare_branches`, + `diff_functions`, `list_branches`. +- Vector search: `vgrep_functions`, `vcommit_similar_commits`, + `vlore_similar_emails`, `lore_search`. -- Function- and type-level lookups: `find_function`, `find_type`, - `find_callers`, `find_calls`, `find_callchain`, `grep_functions`. -- Commit- and branch-level helpers: `find_commit`, - `compare_branches`, `diff_functions`, `list_branches`. -- Vector-indexed search: `vgrep_functions`, - `vcommit_similar_commits`, `vlore_similar_emails`, `lore_search`. - -Raw semcode symbol text is normalised back into a uniform JSON -shape by `parse_semcode_symbol` (`kres-agents/src/symbol.rs:52-59`) -before reaching the fast/slow agents. +Raw semcode symbol text is normalised into a uniform JSON shape +by `parse_semcode_symbol` (`kres-agents/src/symbol.rs`) before +reaching the fast/slow agents. ## When it helps Whole-program questions that read/grep can only approximate — -"who calls `btrfs_search_slot`", "what does the definition of -`struct inode` look like on this branch", "show me every change -to this function in the last 1000 commits". Without semcode the -main agent still answers those, just via more grep round-trips -with more false positives. +"who calls `btrfs_search_slot`", "what does `struct inode` look +like on this branch", "show me every change to this function +over the last 1000 commits". Without semcode the main agent +still answers, just via more grep round-trips and more false +positives. ## Install Either drop `semcode-mcp` on your `PATH` before running -`setup.sh` (it auto-installs `mcp.json`, `setup.sh:265-269`) or -pass `--semcode PATH/TO/semcode-mcp` explicitly -(`setup.sh:41-45`). `--semcode ""` force-skips the MCP install -even when the binary is on `PATH`. kres's `.gitignore` excludes -a `/.semcode.db/` directory at the repo root (`.gitignore:4`) — -that's semcode's on-disk index cache; consult the semcode repo -for details on how it's populated and invalidated. +`setup.sh` (auto-install kicks in), or pass +`--semcode PATH/TO/semcode-mcp` explicitly. `--semcode ""` +force-skips the MCP install even when the binary is on `PATH`. + +kres's `.gitignore` excludes `/.semcode.db/` at the repo root — +semcode's on-disk index cache; consult the semcode repo for how +it's populated and invalidated. diff --git a/docs/summary.md b/docs/summary.md index a995940..4d78a4a 100644 --- a/docs/summary.md +++ b/docs/summary.md @@ -1,28 +1,26 @@ -# Summary output — `/summary`, `--summary`, and `bug-report.txt` +# Summary output — `/summary`, `--summary`, `bug-report.txt` After each task, kres appends the slow agent's narrative to `/report.md` and rewrites `/findings.json` with -the cumulative merged list (the prior turn's canonical file is -copied to `findings-N.json` first, so you have the history). +the cumulative merged list (the previous canonical file is copied +to `findings-N.json` first, preserving history). -At the end of a run you get a plain-text bug report via `/summary` -(or automatically on `--turns` exit, or separately with +A plain-text bug report is produced by `/summary` (or +automatically on `--turns` exit, or standalone via `kres --summary --results `). That run: -- Picks up `/prompt.md` (saved on the first submit so - subsequent `/summary` or `--summary` invocations know the original - question), `/report.md`, and `/findings.json`. -- Uses the fast agent with the `summary` slash-command template - (embedded in the kres binary; overridable at - `~/.kres/commands/summary.md`) as a dedicated system prompt. - `--markdown` selects the `summary-markdown` variant instead. -- Orders the resulting sections by `bug-severity` — `high` → - `medium` → `low` → `latent` → `unknown` — with one section per - bug, each led by `Subject:`, `bug-severity:`, and `bug-impact:` - lines. -- Writes the result to `/bug-report.txt` (or - `bug-report.txt` in the current working directory if you did not - pass `--results`). +- reads `/prompt.md` (saved on first submit so later + summaries know the original question), `/report.md`, + and `/findings.json`; +- calls the fast agent with the embedded `summary` slash-command + template as its system prompt (override at + `~/.kres/commands/summary.md`; `--markdown` picks the + `summary-markdown` variant); +- orders sections by `bug-severity` (`high` → `medium` → `low` → + `latent` → `unknown`), one section per bug headed by + `Subject:`, `bug-severity:`, `bug-impact:` lines; +- writes `/bug-report.txt` (or `bug-report.txt` in cwd + when `--results` was absent). -You can point `--template PATH` at a custom file to override the -shipped summariser prompt without rebuilding. +`--template PATH` overrides the shipped summariser prompt for one +run without rebuilding. diff --git a/docs/turns-and-follow.md b/docs/turns-and-follow.md index 720d09f..d2ebb69 100644 --- a/docs/turns-and-follow.md +++ b/docs/turns-and-follow.md @@ -1,46 +1,32 @@ # `--turns` and `--follow` — stopping the run -`--turns` controls when kres decides a non-interactive run is "done". -A "completed task" throughout this page means a unit that ran all -the way through fast → main → slow and produced a non-empty analysis -(`kres-core/src/task.rs:309-311`). +A "completed task" here means one that went all the way through +fast → main → slow and produced non-empty analysis or code output +(`kres-core/src/task.rs:328-337`). -- **`--turns N` (N ≥ 1)** — stop after N completed tasks. Useful for - a single focused question (`--turns 1`) or a time-boxed review - (`--turns 5` etc.). The REPL exits as soon as the Nth task - finishes, regardless of what the goal agent or the followup queue - look like. `--follow` has no effect in this mode; the run-count - cap wins. +- **`--turns N` (N ≥ 1)** — stop after N completed tasks. The REPL + exits as soon as the Nth task finishes, regardless of the goal + agent or followup queue. `--follow` has no effect here. -- **`--turns 0` (the default)** — no run-count cap. kres trusts the - goal agent: after every task the goal agent checks the accumulated - analysis against the per-task goal; when it declares the goal met, - its handler drains the todo list and the reaper exits on the next - tick (nothing is active, nothing is pending). Until then kres - keeps dispatching the followup tasks the goal check spawns. +- **`--turns 0`** (the default) — no run-count cap. kres trusts the + goal agent: after every task it checks whether the accumulated + analysis satisfies the per-task goal; goal-met drains the todo + list and the reaper exits once nothing is pending or active. - - Add `--follow` to layer a cost cap on top: if 3 consecutive + - Add `--follow` to layer a cost cap: if 3 consecutive analysis-producing runs fail to grow the findings list, exit - even if the goal agent is still saying "not met". Use this when - you want a hard ceiling on how long kres will keep pulling on - threads. - - (`kres-repl/src/session.rs` — see the `turns_limit == 0` branch in - the reaper for the exact predicates. If you run without a - `main-agent.json`, no goal agent is wired up and kres falls back - to "stop when the active batch finishes"; `--follow` switches that - fallback to "drain the todo list with the 3-run stagnation cap".) - -On any `--turns` exit path — run-count cap, goal-met drain, or -stagnation cap — kres - -1. cancels any in-flight work, -2. runs `/summary` automatically, producing `bug-report.txt` - (`bug-report.md` with `--markdown`) in the results directory, or - in the current working directory when `--results` was not given, - and -3. exits. - -Remaining pending or blocked todo items are moved to the "deferred" -list; `/followup` shows them if you re-enter the REPL later, and -`/continue` will dispatch them. + even with the goal agent still saying "not met". + + Without a `main-agent.json` configured there is no goal agent; + kres falls back to "stop when the active batch finishes", and + `--follow` switches that fallback to the 3-run stagnation cap. + See the `turns_limit == 0` branch in `kres-repl/src/session.rs` + for the full predicate. + +On any `--turns` exit — run-count cap, goal-met drain, or +stagnation — kres cancels in-flight work, auto-runs `/summary` +(`bug-report.txt`, or `bug-report.md` with `--markdown`) in the +results dir (cwd when `--results` was absent), and exits. +Remaining pending / blocked todos move to the deferred list; +`/followup` lists them and `/continue` dispatches them if you +re-enter the REPL. From dfb16bbb640d82ef8dcc972f5098073f35283319 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 11:16:45 -0700 Subject: [PATCH 30/76] docs: drop dead planning.md link, fix /summary role description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents.md referenced a TBD docs/planning.md that was never written; the only file at that path on disk is an untracked stray git-log dump. Drop the parenthetical so readers don't chase a broken link. commands.md claimed all three shipped slash-commands compose a template body with a trailing target via user_commands::compose. That only describes /review — /summary and /summary-markdown call user_commands::lookup (kres-repl/src/summary.rs:141) to fetch the body and pass it as the system prompt for a fast-agent call over report.md + findings.json, with no target composition. Split the bullet list so the two roles are distinct. Signed-off-by: Chris Mason --- docs/agents.md | 2 +- docs/commands.md | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index aec3802..52faec3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -17,7 +17,7 @@ Every task cycles through these roles, all configured under structured findings. - **todo** (`todo-agent.json`) — dedups the slow agent's followups against the current todo list, reprioritises, and - may reshape the plan (see [planning.md](planning.md) — TBD). + may reshape the plan. - **merger** — non-agent fast-client call that folds new findings into the cumulative list; supersedes become `invalidated`. diff --git a/docs/commands.md b/docs/commands.md index 42c3317..d879f91 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -12,18 +12,18 @@ override or add commands by dropping a file at | `summary` | `kres --summary --results DIR` | `/summary [filename]` | | `summary-markdown` | `kres --summary --markdown --results DIR` | `/summary-markdown [filename]` | -Both CLI and REPL forms compose the template body with the -trailing target via `user_commands::compose` and submit the -result as a new task. - -The shipped three: - -- `review` — parallel-lens review template (see - [review-template.md](review-template.md)); invocation prepends - the operator's target to the template body. -- `summary` — plain-text bug-report system prompt passed to the - fast agent by `/summary` and `kres --summary`. -- `summary-markdown` — markdown variant selected by `--markdown`. +The three shipped templates play two different roles: + +- `review` — a task prompt. CLI and REPL invocations prepend + the operator's target to the template body via + `user_commands::compose` and submit the result as a new task + (see [review-template.md](review-template.md)). +- `summary` — a system prompt. `/summary` and `kres --summary` + feed the template body to the fast agent alongside the run's + `report.md` + `findings.json` to render `bug-report.txt` + (`kres-repl/src/summary.rs`). No target composition. +- `summary-markdown` — identical path; selected by `--markdown` + and writes `bug-report.md`. Adding your own: drop `~/.kres/commands/audit.md` and run `kres --prompt 'audit: net/...'` or `/audit net/...`. No rebuild From 3e1150d96a7a0d7a7dce25157f1c9ecb57d5c25d Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 11:16:55 -0700 Subject: [PATCH 31/76] core: preserve Done todos across goal-met / turns / /stop drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal-met, --turns, --turns 0 stagnation, and /stop paths all drained pending items into the deferred ledger and then called replace_todo(Vec::new()), which silently discarded any Done or Skipped todos sitting on the list. The todo-agent tags each emitted todo with step_id pointing at the plan step it executes, and sync_plan_from_todo relies on those linked todos to roll a step up to Done. Wiping the Done entries stripped that linkage, so a step whose remaining linked todos are all terminal could not flip to Done — the plan stayed fully Pending for the rest of the session even after goal-met, and /plan lied about what had actually shipped. An inode.c review run hit exactly this: session.json recorded 11 plan steps still Pending, 0 todos, and 33 deferred items with populated step_ids — the Done items that would have completed four of those steps had been thrown away at goal-met. Fix by adding TaskManager::drain_pending_blocked, which partitions Pending/Blocked out into the caller's Vec and leaves Done/Skipped on the manager's list. Swap it in at all four drain sites; /stop now flips InProgress → Pending first so drain_pending_blocked carries them too. sync_plan_from_todo on the next persist tick sees a fully-terminal linkage for any step whose pending siblings were just deferred, and the step flips to Done the way the plan doc claims it does. Signed-off-by: Chris Mason --- kres-core/src/task.rs | 72 +++++++++++++++++++++++++++ kres-repl/src/session.rs | 102 ++++++++++++++------------------------- 2 files changed, 109 insertions(+), 65 deletions(-) diff --git a/kres-core/src/task.rs b/kres-core/src/task.rs index cd95ef8..89740d4 100644 --- a/kres-core/src/task.rs +++ b/kres-core/src/task.rs @@ -544,6 +544,26 @@ impl TaskManager { n } + /// Remove and return all `Pending` and `Blocked` todos. Done and + /// Skipped items stay on the manager's list so the next + /// `sync_plan_from_todo` pass can still roll a plan step up to + /// Done when its remaining linked todos are all terminal — the + /// goal-met / --turns drains used to clear the todo list + /// wholesale via `replace_todo(Vec::new())`, which erased the + /// `step_id` linkage from completed work and pinned every plan + /// step at Pending for the rest of the session. + /// + /// Callers that want InProgress items drained too should flip + /// them first with `reset_in_progress_to_pending`. + pub async fn drain_pending_blocked(&self) -> Vec { + let mut g = self.inner.write().await; + let (drain, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut g.todo) + .into_iter() + .partition(|i| matches!(i.status, TodoStatus::Pending | TodoStatus::Blocked)); + g.todo = keep; + drain + } + // -- plan ---------------------------------------------------------- pub async fn plan_snapshot(&self) -> Option { @@ -769,6 +789,58 @@ mod tests { assert_eq!(snap[3].status, TodoStatus::Pending); } + #[tokio::test] + async fn drain_pending_blocked_keeps_terminal_items() { + // Goal-met / --turns drains used to wipe the todo list via + // replace_todo(Vec::new()), erasing Done items' step_id + // linkage so the plan could never roll up to Done. The new + // drain keeps Done/Skipped on the list. + let mgr = TaskManager::new(); + let mut a = TodoItem::new("a", "investigate"); + a.status = TodoStatus::Pending; + let mut b = TodoItem::new("b", "investigate"); + b.status = TodoStatus::Blocked; + let mut c = TodoItem::new("c", "investigate"); + c.status = TodoStatus::Done; + let mut d = TodoItem::new("d", "investigate"); + d.status = TodoStatus::Skipped; + mgr.replace_todo(vec![a, b, c, d]).await; + let drained = mgr.drain_pending_blocked().await; + let drained_names: Vec<_> = drained.iter().map(|i| i.name.clone()).collect(); + assert_eq!(drained_names, vec!["a".to_string(), "b".to_string()]); + let snap = mgr.todo_snapshot().await; + let kept: Vec<_> = snap.iter().map(|i| i.name.clone()).collect(); + assert_eq!(kept, vec!["c".to_string(), "d".to_string()]); + } + + #[tokio::test] + async fn drain_preserves_step_id_linkage_for_plan_rollup() { + // End-to-end guard for the bug that inspired the drain + // change: a step with two linked todos, one done / one + // pending. Pre-fix the pending todo drained AND the done + // todo was wiped, leaving the step pending forever. Post- + // fix the done todo stays, sync_plan_from_todo sees a + // fully-terminal linkage, and the step flips to Done. + use crate::plan::{Plan, PlanStep, PlanStepStatus}; + let mgr = TaskManager::new(); + let mut plan = Plan::new("p", "g", crate::TaskMode::Analysis); + plan.steps.push(PlanStep::new("s1", "audit")); + mgr.set_plan(Some(plan)).await; + let mut a = TodoItem::new("a", "investigate"); + a.step_id = "s1".into(); + a.status = TodoStatus::Done; + let mut b = TodoItem::new("b", "investigate"); + b.step_id = "s1".into(); + b.status = TodoStatus::Pending; + mgr.replace_todo(vec![a, b]).await; + let drained = mgr.drain_pending_blocked().await; + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].name, "b"); + mgr.sync_plan_from_todo().await; + let out = mgr.plan_snapshot().await.unwrap(); + assert_eq!(out.steps[0].status, PlanStepStatus::Done); + } + #[tokio::test] async fn set_and_sync_plan_marks_step_done_when_todos_terminal() { use crate::plan::{Plan, PlanStep, PlanStepStatus}; diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 0b96b65..6f863f7 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -1248,19 +1248,16 @@ impl Session { mgr_for_reaper.reset_in_progress_to_pending().await; // Drain pending todos into the deferred // ledger so /followup can list them. - let remaining = mgr_for_reaper.todo_snapshot().await; + // Done/Skipped items stay on the todo + // list so their step_id linkage survives + // — the next sync_plan_from_todo tick + // can then flip any fully-covered plan + // step to Done. + let drained = mgr_for_reaper.drain_pending_blocked().await; + let carry = drained.len(); let mut deferred = deferred_for_reaper.lock().await; - let mut carry = 0usize; - for item in remaining { - if item.status == kres_core::TodoStatus::Pending - || item.status == kres_core::TodoStatus::Blocked - { - deferred.push(item); - carry += 1; - } - } + deferred.extend(drained); drop(deferred); - mgr_for_reaper.replace_todo(Vec::new()).await; if carry > 0 { kres_core::async_eprintln!( "[{carry} pending item(s) moved to deferred — run /followup to list, /continue to pursue]" @@ -1371,21 +1368,15 @@ impl Session { mgr_for_reaper.reset_in_progress_to_pending().await; // §32: move every pending/blocked todo item // to the deferred list so /followup can list - // them, then clear the todo list. Matches - let remaining = mgr_for_reaper.todo_snapshot().await; + // them. Done/Skipped items stay on the todo + // list so their step_id linkage is still + // available for sync_plan_from_todo on the + // next persist tick. + let drained = mgr_for_reaper.drain_pending_blocked().await; + let carry = drained.len(); let mut deferred = deferred_for_reaper.lock().await; - let mut carry = 0usize; - for item in remaining { - if matches!( - item.status, - kres_core::TodoStatus::Pending | kres_core::TodoStatus::Blocked - ) { - deferred.push(item); - carry += 1; - } - } + deferred.extend(drained); drop(deferred); - mgr_for_reaper.replace_todo(Vec::new()).await; if carry > 0 { kres_core::async_eprintln!( "[{carry} pending item(s) deferred — see /followup]" @@ -1493,28 +1484,17 @@ impl Session { // disappear. mgr_for_reaper.reset_in_progress_to_pending().await; // Move any leftover pending/blocked items to - // /followup's deferred list and clear the - // active queue so auto-continue doesn't - // immediately redispatch them. Unlike the - // --turns N path we do NOT cancel the root - // shutdown or flag turns_exhausted — the user - // wants to keep driving the REPL after goal - // met. - let remaining = mgr_for_reaper.todo_snapshot().await; + // /followup's deferred list. Done/Skipped + // items stay so the plan step rollup can + // still see them. Unlike the --turns N path + // we do NOT cancel the root shutdown or flag + // turns_exhausted — the user wants to keep + // driving the REPL after goal met. + let drained = mgr_for_reaper.drain_pending_blocked().await; + let carry = drained.len(); let mut deferred = deferred_for_reaper.lock().await; - let mut carry = 0usize; - for item in remaining { - if matches!( - item.status, - kres_core::TodoStatus::Pending - | kres_core::TodoStatus::Blocked - ) { - deferred.push(item); - carry += 1; - } - } + deferred.extend(drained); drop(deferred); - mgr_for_reaper.replace_todo(Vec::new()).await; if carry > 0 { kres_core::async_eprintln!( "[{carry} pending item(s) moved to /followup]" @@ -2161,29 +2141,21 @@ impl Session { self.stop_latched .store(true, std::sync::atomic::Ordering::Release); // Move pending / blocked / in-progress todo items to the - // deferred list and clear the active queue. Otherwise - // /stop leaves the queue full and the next /continue (or - // the reaper's goal-not-met injection after the next task - // completes) immediately redispatches what the operator - // just stopped. Operator can get them back with /followup. - let remaining = self.mgr.todo_snapshot().await; + // deferred list. Done/Skipped items stay on the active + // queue so the plan step rollup in sync_plan_from_todo can + // still see their step_id linkage. Flip InProgress to + // Pending first so `drain_pending_blocked` carries them + // with the rest. Otherwise /stop leaves the queue full and + // the next /continue (or the reaper's goal-not-met + // injection after the next task completes) immediately + // redispatches what the operator just stopped. Operator + // can get them back with /followup. + self.mgr.reset_in_progress_to_pending().await; + let drained = self.mgr.drain_pending_blocked().await; + let carry = drained.len(); let mut deferred = self.deferred.lock().await; - let mut carry = 0usize; - for item in remaining { - if matches!( - item.status, - kres_core::TodoStatus::Pending - | kres_core::TodoStatus::Blocked - | kres_core::TodoStatus::InProgress - ) { - deferred.push(item); - carry += 1; - } - } + deferred.extend(drained); drop(deferred); - if carry > 0 { - self.mgr.replace_todo(Vec::new()).await; - } println!( "/stop: requested={} stopped={} grace_expired={} (auto-continue paused; {} pending item(s) moved to /followup; /continue or a new prompt resumes)", out.requested, out.stopped, out.grace_expired, carry From 957a091eeef48077a912991835e5f9f0d1730f65 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 13:28:48 -0700 Subject: [PATCH 32/76] agents: rescue slow-agent prose replies via fast-agent translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slow-agent system prompt at configs/prompts/slow-code-agent.system.md:23 demands "Output: JSON only, no fences, no preamble" and at :37 warns "a bug that exists only in prose will be LOST" — but the model occasionally ignores the instruction and emits a bare markdown analysis. When that happens parse_code_response falls through to ParseStrategy::RawText, the prose survives as `analysis` but `findings` stays empty, and any bug claims the model made only in prose are dropped on the floor by the merger. An inode.c review session hit this on two slow turns (code.jsonl lines 168 and 171), each concluding with a concrete finding claim in prose — "The read_inline_extent_bs_gt_ps_oob_write finding is confirmed reachable…" and a CONFIG_PANIC_ON_WARN kernel-panic claim — that never reached the findings pipeline. Fix by invoking the fast agent to translate the prose back into the JSON envelope when the slow call produces RawText. The translation prompt forbids inventing new bugs (transcribe only) and lists the Finding schema inline. If the translation itself can't be parsed, keep the original RawText result so the prose content isn't lost either way. The call deliberately skips fast_system (wrong schema target) and the prompt cache (hits too rarely to justify a breakpoint). Signed-off-by: Chris Mason --- kres-agents/src/pipeline.rs | 130 +++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index ed92bae..9befc91 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -654,17 +654,45 @@ impl Orchestrator { t } }; - let slow_parsed = parse_code_response(&text); + let mut slow_parsed = parse_code_response(&text); // bugs.md#M3: surface the non-JSON case instead of letting it // masquerade as a valid-but-empty analysis. The strategy // field is also on TaskSummary for callers that want to // react in-band. + // + // Rescue path: when the slow agent returned pure prose, any + // bug claims in that prose would otherwise be lost (findings + // stays empty, the merger has nothing to promote — see + // slow-code-agent.system.md:37 "a bug that exists only in + // prose will be LOST"). Ask the fast agent to translate the + // prose into the expected envelope. If the translation also + // fails to produce parseable JSON, keep the original + // RawText result so the prose at least survives as analysis. if slow_parsed.strategy == ParseStrategy::RawText { tracing::warn!( target: "kres_agents", fast_rounds, - "slow agent returned no parseable JSON; analysis contains raw text" + "slow agent returned no parseable JSON; attempting fast-agent translation" ); + match self + .translate_slow_raw_text(&slow_parsed.analysis, &ctx.task_brief, shutdown) + .await + { + Some(translated) => { + kres_core::async_eprintln!( + "[slow] rescued via fast-agent translation: {} finding(s), {} followup(s)", + translated.findings.len(), + translated.followups.len(), + ); + slow_parsed = translated; + } + None => { + tracing::warn!( + target: "kres_agents", + "fast-agent translation failed; prose preserved as analysis with no structured findings" + ); + } + } } kres_core::async_eprintln!( "[slow] parsed: analysis {}k chars, {} finding(s), {} followup(s), strategy={:?}", @@ -731,6 +759,104 @@ impl Orchestrator { plan: slow_plan, }) } + + /// Re-emit a prose-only slow-agent reply as the JSON envelope the + /// pipeline expects. Invoked from `run_once_with_ctx` when the + /// slow call produced `ParseStrategy::RawText`. The fast agent is + /// told to transcribe — not augment — so this should not invent + /// findings the prose doesn't already make. + /// + /// Returns the reparsed response, or `None` when the translation + /// itself couldn't be parsed (the caller then keeps the original + /// prose-as-analysis result so the content isn't lost entirely). + /// Deliberately skips `self.fast_system` — the fast-agent system + /// prompt pushes toward the fast-agent schema (ready_for_slow / + /// skill_reads), which is the wrong target here. Also skips the + /// prompt cache — this path fires on the rare non-JSON turn, so + /// the ~4KB translation prompt isn't worth a breakpoint. + async fn translate_slow_raw_text( + &self, + prose: &str, + task_brief: &str, + shutdown: &Shutdown, + ) -> Option { + let user_content = format!( + "The slow agent returned the analysis below as free-form prose \ + instead of the required JSON envelope. Re-emit the SAME CONTENT \ + as strict JSON with this shape:\n\ + {{\"analysis\": \"\", \"findings\": [, ...], \ + \"followups\": [{{\"type\": \"T\", \"name\": \"N\", \"reason\": \"R\"}}]}}\n\n\ + Rules:\n\ + - Do NOT invent new bugs or analysis. Transcribe the prose.\n\ + - Every actionable bug described in the prose MUST appear as a \ + Finding record with this schema: id (snake_case slug), title, \ + severity (low|medium|high|critical), status ('active'), \ + relevant_symbols (array of {{name, filename, line, definition}}), \ + relevant_file_sections (array of {{filename, line_start, \ + line_end, content}}), summary, reproducer_sketch, impact. \ + Optional: mechanism_detail, fix_sketch, open_questions, \ + related_finding_ids.\n\ + - If the prose made a bug claim without enough detail for a \ + concrete Finding (no file:line, no reproducer), omit it \ + rather than fabricate fields.\n\ + - Output JSON only, no fences, no preamble.\n\n\ + ---\n\ + Task brief: {task_brief}\n\n\ + Prose analysis to translate:\n\n{prose}" + ); + let messages = vec![Message { + role: "user".into(), + content: user_content.clone(), + cache: false, + cached_prefix: None, + }]; + let mut cfg = CallConfig::defaults_for(self.fast_model.clone()) + .with_max_tokens(self.fast_max_tokens) + .with_stream_label("fast translate raw slow"); + if let Some(n) = self.fast_max_input_tokens { + cfg = cfg.with_max_input_tokens(n); + } + if let Some(lg) = &self.logger { + lg.log_code("user", &user_content, None, None); + } + let text = tokio::select! { + _ = shutdown.cancelled() => return None, + r = self.fast_client.messages_streaming(&cfg, &messages) => { + match r { + Ok(resp) => { + record_usage(&self.usage, "fast", &self.fast_model, &resp.usage); + let t = extract_text(&resp); + if let Some(lg) = &self.logger { + let thinking = extract_thinking(&resp); + lg.log_code( + "assistant", + &t, + Some(log_usage(&resp.usage)), + thinking.as_deref(), + ); + } + t + } + Err(e) => { + tracing::warn!( + target: "kres_agents", + "raw-text translation call failed: {e}" + ); + return None; + } + } + } + }; + let reparsed = parse_code_response(&text); + if reparsed.strategy == ParseStrategy::RawText { + tracing::warn!( + target: "kres_agents", + "raw-text translation also returned non-JSON" + ); + return None; + } + Some(reparsed) + } } impl Orchestrator { From cd093a3e6b6f7d1136999377401dcea10a82ffaf Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 14:17:05 -0700 Subject: [PATCH 33/76] docs: fold semcode + review-prompts into configuration.md, drop NEWS.md NEWS.md duplicates what git log already tells you; keeping it as a separate file means every release touches two places. Drop it. docs/semcode.md and docs/review-prompts.md each described one ~/.kres/ install option. Their natural home is alongside the rest of the install / config reference in docs/configuration.md, so fold both in as "semcode MCP integration" and "Kernel review prompts" sections and remove the originals. README.md's Quick start now notes that both integrations are optional and points at configuration.md. Further reading drops the three removed entries and expands the configuration.md line to mention the new sections. docs/development.md drops NEWS.md from the workspace tree. Signed-off-by: Chris Mason --- NEWS.md | 20 ----------- README.md | 29 +++++++-------- docs/configuration.md | 82 ++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 1 - docs/review-prompts.md | 27 -------------- docs/semcode.md | 53 --------------------------- 6 files changed, 94 insertions(+), 118 deletions(-) delete mode 100644 NEWS.md delete mode 100644 docs/review-prompts.md delete mode 100644 docs/semcode.md diff --git a/NEWS.md b/NEWS.md deleted file mode 100644 index be10533..0000000 --- a/NEWS.md +++ /dev/null @@ -1,20 +0,0 @@ -# NEWS - -## April 22 - -Agent system prompts and slash-command templates are now embedded -in the kres binary — rebuilding kres refreshes them. `setup.sh` -no longer copies `*.system.md`, `bug-summary*.md`, or -`review-template.md` anywhere. - -Stale files left under `~/.kres/prompts/` from earlier installs -are ignored and safe to delete. See -[docs/configuration.md](docs/configuration.md) for the override -paths and [docs/commands.md](docs/commands.md) for the -slash-command templates. - -## April 21 - -New support for writing patches: `--prompt 'fix …'` classifies -the task as **coding mode** and produces in-place edits plus -fresh files. See [docs/coding-tasks.md](docs/coding-tasks.md). diff --git a/README.md b/README.md index e4db02f..6011812 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,9 @@ reviewing, auditing, and finding bugs in C source trees. The Linux kernel is the primary target; any large C codebase with source-level tooling works too. -## Why kres exists +## kres introduction -A single LLM call over a C source file produces a prose summary -that reads well but misses bugs — it has no structured way to -dedup what it already covered, no budget left after loading the -code for deep thinking, and no memory across questions. kres -splits the job across cooperating roles: +kres splits the job of reviewing code across a number of cooperating agents: - **fast** scopes the work, picks the code to look at, and emits a structured brief for deeper analysis. @@ -28,12 +24,8 @@ splits the job across cooperating roles: deduplicated bug list; old findings get `invalidated` when a later one supersedes them. -The REPL ties these together: one `--prompt 'review: X'` seeds a -lens fan-out that audits `X` under five parallel angles (object -lifetime, memory, bounds, races, general correctness), and -follow-up tasks chase the threads the slow agent flags — all -with persistent plan, todo list, and findings that survive -interruptions. +The results of every turn are used to reprioritize the todo list, and identify +additional context needed for the next round. See [docs/agents.md](docs/agents.md) for the task flow and [docs/review-template.md](docs/review-template.md) for the @@ -77,9 +69,14 @@ parallel-lens review. [docs/turns-and-follow.md](docs/turns-and-follow.md) for the other stop modes. +Two optional integrations are worth wiring up while you're +here: semcode-mcp for whole-program code navigation and the +kernel `review-prompts` repo for subsystem knowledge. Both are +configured via `setup.sh` flags — see +[docs/configuration.md](docs/configuration.md) for details. + ## Further reading -- [NEWS.md](NEWS.md) — recent changes. - [docs/agents.md](docs/agents.md) — fast / main / slow / todo / merger flow and how follow-up tasks drive larger reviews. - [docs/review-template.md](docs/review-template.md) — the @@ -94,12 +91,10 @@ parallel-lens review. non-MCP tools the main agent can dispatch and how to change that. - [docs/configuration.md](docs/configuration.md) — `~/.kres/` - layout, model selection, and system-prompt overrides. + layout, model selection, system-prompt overrides, semcode MCP + integration, and kernel review-prompts setup. - [docs/commands.md](docs/commands.md) — slash-command templates (`/review`, `/summary`, operator-authored additions). -- [docs/review-prompts.md](docs/review-prompts.md) — integrating - the separate `review-prompts` repo with the kernel skill. -- [docs/semcode.md](docs/semcode.md) — semcode-mcp integration. - [docs/cli.md](docs/cli.md) — every CLI flag and REPL command. - [docs/development.md](docs/development.md) — workspace layout, build / test / lint, pre-commit hook. diff --git a/docs/configuration.md b/docs/configuration.md index 08252e8..9c27441 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,3 +79,85 @@ directory at `~/.kres/commands/` — see that leftover files from older installs under `~/.kres/prompts/` never shadow the embedded defaults; stale files there are safe to delete. + +## semcode MCP integration + +The main agent's code navigation is enhanced by semcode +(). When a +`semcode-mcp` binary is on `PATH`, `setup.sh` writes an +`mcp.json` that launches it as an MCP child: + +```json +{ + "mcpServers": { + "semcode": { "command": "semcode-mcp" } + } +} +``` + +kres works without semcode — the main agent already answers +code questions with `read`, `grep`, and `git`. semcode adds a +function/type/callchain-aware index so the agent can ask +whole-program questions directly instead of deriving them from +raw regex. + +Tools the main agent will call when wired up: + +- Symbols: `find_function`, `find_type`, `find_callers`, + `find_calls`, `find_callchain`, `grep_functions`. +- Commits / branches: `find_commit`, `compare_branches`, + `diff_functions`, `list_branches`. +- Vector search: `vgrep_functions`, `vcommit_similar_commits`, + `vlore_similar_emails`, `lore_search`. + +Raw semcode symbol text is normalised into a uniform JSON shape +by `parse_semcode_symbol` (`kres-agents/src/symbol.rs`) before +reaching the fast/slow agents. + +### When it helps + +Whole-program questions that read/grep can only approximate — +"who calls `btrfs_search_slot`", "what does `struct inode` look +like on this branch", "show me every change to this function +over the last 1000 commits". Without semcode the main agent +still answers, just via more grep round-trips and more false +positives. + +### Install + +Either drop `semcode-mcp` on your `PATH` before running +`setup.sh` (auto-install kicks in), or pass +`--semcode PATH/TO/semcode-mcp` explicitly. `--semcode ""` +force-skips the MCP install even when the binary is on `PATH`. + +kres's `.gitignore` excludes `/.semcode.db/` at the repo root — +semcode's on-disk index cache; consult the semcode repo for how +it's populated and invalidated. + +## Kernel review prompts + +Subsystem knowledge for the kernel lives in a separate repo: +. + +`skills/kernel.md` is a thin loader that references +`@REVIEW_PROMPTS@/kernel/technical-patterns.md` as a mandatory +read on every slow-agent turn, plus +`@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` as the index +into per-subsystem guides. `setup.sh` substitutes +`@REVIEW_PROMPTS@` with an on-disk path at install time. + +Point `setup.sh` at your clone: + +``` +./setup.sh --fast-key $FAST_API_KEY --slow-key $SLOW_API_KEY \ + --review-prompts /path/to/review-prompts +``` + +Without a resolvable path, `setup.sh` leaves the kernel skill +uninstalled — agents still run, but the slow agent loses the +pattern catalogue and subsystem context. + +When `--review-prompts` is omitted, `setup.sh` peeks at +`~/.claude/skills/kernel/SKILL.md` and offers the first +review-prompts path it finds there. Pass `--review-prompts PATH` +to bypass the interactive prompt. diff --git a/docs/development.md b/docs/development.md index bf2ee34..51f76e2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,7 +26,6 @@ kres/ ├── CLAUDE.md project instructions for Claude Code ├── setup.sh bootstrap ~/.kres/ from configs/ ├── .githooks/pre-commit runs cargo fmt + clippy on every commit -├── NEWS.md └── README.md ``` diff --git a/docs/review-prompts.md b/docs/review-prompts.md deleted file mode 100644 index 31fb891..0000000 --- a/docs/review-prompts.md +++ /dev/null @@ -1,27 +0,0 @@ -# Kernel review prompts - -Subsystem knowledge for the kernel lives in a separate repo: -. - -`skills/kernel.md` is a thin loader that references -`@REVIEW_PROMPTS@/kernel/technical-patterns.md` as a mandatory -read on every slow-agent turn, plus -`@REVIEW_PROMPTS@/kernel/subsystem/subsystem.md` as the index -into per-subsystem guides. `setup.sh` substitutes -`@REVIEW_PROMPTS@` with an on-disk path at install time. - -Point `setup.sh` at your clone: - -``` -./setup.sh --fast-key $FAST_API_KEY --slow-key $SLOW_API_KEY \ - --review-prompts /path/to/review-prompts -``` - -Without a resolvable path, `setup.sh` leaves the kernel skill -uninstalled — agents still run, but the slow agent loses the -pattern catalogue and subsystem context. - -When `--review-prompts` is omitted, `setup.sh` peeks at -`~/.claude/skills/kernel/SKILL.md` and offers the first -review-prompts path it finds there. Pass `--review-prompts PATH` -to bypass the interactive prompt. diff --git a/docs/semcode.md b/docs/semcode.md deleted file mode 100644 index ff1e723..0000000 --- a/docs/semcode.md +++ /dev/null @@ -1,53 +0,0 @@ -# semcode MCP integration - -The main agent's code navigation is enhanced by semcode -(). When a -`semcode-mcp` binary is on `PATH`, `setup.sh` writes an -`mcp.json` that launches it as an MCP child: - -```json -{ - "mcpServers": { - "semcode": { "command": "semcode-mcp" } - } -} -``` - -kres works without semcode — the main agent already answers -code questions with `read`, `grep`, and `git`. semcode adds a -function/type/callchain-aware index so the agent can ask -whole-program questions directly instead of deriving them from -raw regex. - -Tools the main agent will call when wired up: - -- Symbols: `find_function`, `find_type`, `find_callers`, - `find_calls`, `find_callchain`, `grep_functions`. -- Commits / branches: `find_commit`, `compare_branches`, - `diff_functions`, `list_branches`. -- Vector search: `vgrep_functions`, `vcommit_similar_commits`, - `vlore_similar_emails`, `lore_search`. - -Raw semcode symbol text is normalised into a uniform JSON shape -by `parse_semcode_symbol` (`kres-agents/src/symbol.rs`) before -reaching the fast/slow agents. - -## When it helps - -Whole-program questions that read/grep can only approximate — -"who calls `btrfs_search_slot`", "what does `struct inode` look -like on this branch", "show me every change to this function -over the last 1000 commits". Without semcode the main agent -still answers, just via more grep round-trips and more false -positives. - -## Install - -Either drop `semcode-mcp` on your `PATH` before running -`setup.sh` (auto-install kicks in), or pass -`--semcode PATH/TO/semcode-mcp` explicitly. `--semcode ""` -force-skips the MCP install even when the binary is on `PATH`. - -kres's `.gitignore` excludes `/.semcode.db/` at the repo root — -semcode's on-disk index cache; consult the semcode repo for how -it's populated and invalidated. From deebf0484c62300e82336a6e4e8c9feacfeffc06 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 15:46:56 -0700 Subject: [PATCH 34/76] chore: cargo fmt + quiet rust-1.94.0 clippy lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-1.94.0 raised several new clippy lints and rustfmt learned a few new reflow rules. The pre-commit hook fails on both, so the first touch to rust sources after the toolchain bump has to absorb the mechanical cleanup before anything else lands. Clippy fixes: derive Default on TaskMode, swap matches! Err(_) for is_err(), collapse a manual clamp/split_once/char-matches into the dedicated helpers, and add a blank line before the tail sentence of two doc comments so they stop looking like list continuations. Everything else is rustfmt output — no functional change intended. Signed-off-by: Chris Mason --- kres-agents/src/config.rs | 25 +++------- kres-agents/src/embedded_prompts.rs | 11 +---- kres-agents/src/fetcher.rs | 3 +- kres-agents/src/goal.rs | 1 - kres-agents/src/lib.rs | 3 +- kres-agents/src/main_agent.rs | 75 +++++++++-------------------- kres-agents/src/pipeline.rs | 20 +++----- kres-agents/src/response.rs | 3 +- kres-agents/src/todo_agent.rs | 8 +-- kres-agents/src/tools.rs | 40 +++++++-------- kres-agents/src/user_commands.rs | 36 +++++--------- kres-core/src/consent.rs | 44 ++++------------- kres-core/src/mode.rs | 9 +--- kres-core/src/plan.rs | 15 +++--- kres-core/src/task.rs | 6 +-- kres-repl/src/settings.rs | 47 ++++++------------ 16 files changed, 108 insertions(+), 238 deletions(-) diff --git a/kres-agents/src/config.rs b/kres-agents/src/config.rs index 776e765..f63000f 100644 --- a/kres-agents/src/config.rs +++ b/kres-agents/src/config.rs @@ -125,13 +125,8 @@ impl AgentConfig { cfg.system = Some(body); } Err(disk_err) => { - let basename = resolved - .file_name() - .and_then(|o| o.to_str()) - .unwrap_or(""); - if let Some(embedded) = - crate::embedded_prompts::lookup(basename) - { + let basename = resolved.file_name().and_then(|o| o.to_str()).unwrap_or(""); + if let Some(embedded) = crate::embedded_prompts::lookup(basename) { cfg.system = Some(embedded.to_string()); } else { return Err(AgentError::Other(format!( @@ -289,9 +284,7 @@ mod tests { // (the `.system.md` table is agent-role specific) and the // disk path is absent → both fallbacks fail and the caller // gets a clear error. - let p = write_tmp( - r#"{"key": "sk-x", "system_file": "/tmp/does-not-exist-kres-test.md"}"#, - ); + let p = write_tmp(r#"{"key": "sk-x", "system_file": "/tmp/does-not-exist-kres-test.md"}"#); let e = AgentConfig::load(&p).unwrap_err(); let msg = format!("{e}"); assert!(msg.contains("system_file"), "got: {msg}"); @@ -310,10 +303,8 @@ mod tests { // instead of erroring. This test targets `main-agent.system.md` // because that name is guaranteed present in the embedded // table. - let dir = std::env::temp_dir().join(format!( - "kres-sysfile-embedded-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("kres-sysfile-embedded-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); // Pointing at a nonexistent sibling file whose basename // matches an embedded key. @@ -341,10 +332,8 @@ mod tests { // An operator's custom copy at the referenced path must // take precedence over the embedded one — this is the // override path. - let dir = std::env::temp_dir().join(format!( - "kres-sysfile-override-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("kres-sysfile-override-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); // Shadow the embedded main-agent prompt with a tiny // operator-supplied one. Same basename, different body. diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs index 33578ce..6117814 100644 --- a/kres-agents/src/embedded_prompts.rs +++ b/kres-agents/src/embedded_prompts.rs @@ -58,10 +58,7 @@ const TABLE: &[(&str, &str)] = &[ /// `"main-agent.system.md"` for a config field /// `"prompts/main-agent.system.md"`). pub fn lookup(basename: &str) -> Option<&'static str> { - TABLE - .iter() - .find(|(k, _)| *k == basename) - .map(|(_, v)| *v) + TABLE.iter().find(|(k, _)| *k == basename).map(|(_, v)| *v) } /// Every basename that has an embedded copy. Useful for logging / @@ -78,10 +75,7 @@ mod tests { fn every_embedded_prompt_is_non_empty() { for name in embedded_names() { let body = lookup(name).expect("lookup must succeed for listed name"); - assert!( - !body.trim().is_empty(), - "embedded prompt {name} is empty" - ); + assert!(!body.trim().is_empty(), "embedded prompt {name} is empty"); } } @@ -114,5 +108,4 @@ mod tests { ); } } - } diff --git a/kres-agents/src/fetcher.rs b/kres-agents/src/fetcher.rs index 5359864..9e774df 100644 --- a/kres-agents/src/fetcher.rs +++ b/kres-agents/src/fetcher.rs @@ -27,8 +27,7 @@ use crate::{ followup::Followup, pipeline::{DataFetcher, FetchResult}, tools::{ - bash_run, find, git, grep, read_file_range, BashArgs, FindArgs, GitArgs, GrepArgs, - ReadArgs, + bash_run, find, git, grep, read_file_range, BashArgs, FindArgs, GitArgs, GrepArgs, ReadArgs, }, }; diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index 28fa4f2..be664f0 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -665,5 +665,4 @@ mod tests { assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].id, "step-1"); } - } diff --git a/kres-agents/src/lib.rs b/kres-agents/src/lib.rs index d139aca..9248e21 100644 --- a/kres-agents/src/lib.rs +++ b/kres-agents/src/lib.rs @@ -31,8 +31,7 @@ pub use error::AgentError; pub use fetcher::{parse_read_spec, WorkspaceFetcher}; pub use followup::Followup; pub use goal::{ - check_goal, define_goal, define_plan, GoalCheck, GoalClient, GoalDefinition, - GOAL_INSTRUCTIONS, + check_goal, define_goal, define_plan, GoalCheck, GoalClient, GoalDefinition, GOAL_INSTRUCTIONS, }; pub use kres_core::TaskMode; pub use main_agent::{parse_actions, MainAgent, DEFAULT_MAX_MAIN_TURNS}; diff --git a/kres-agents/src/main_agent.rs b/kres-agents/src/main_agent.rs index d807d25..89fd1bf 100644 --- a/kres-agents/src/main_agent.rs +++ b/kres-agents/src/main_agent.rs @@ -453,8 +453,7 @@ async fn dispatch_non_mcp( ) -> (String, Option) { let ty = action.get("type").and_then(|v| v.as_str()).unwrap_or("?"); if ty != "?" && !allowed_actions.contains(ty) { - let allowed_list: Vec<&str> = - allowed_actions.iter().map(|s| s.as_str()).collect(); + let allowed_list: Vec<&str> = allowed_actions.iter().map(|s| s.as_str()).collect(); let list_display = if allowed_list.is_empty() { "none — every non-MCP action is denied this session".to_string() } else { @@ -666,10 +665,7 @@ async fn dispatch_non_mcp( .get("timeout_secs") .or_else(|| action.get("timeout")) .and_then(|v| v.as_u64()); - let cwd = action - .get("cwd") - .and_then(|v| v.as_str()) - .map(String::from); + let cwd = action.get("cwd").and_then(|v| v.as_str()).map(String::from); let args = BashArgs { command, timeout_secs, @@ -856,10 +852,7 @@ mod tests { // Regression: the dispatcher used to read only `name`, so a // model-emitted {"type":"find","pattern":"report.md"} ran // find(1) with no -name filter and dumped the workspace tree. - let tmp = std::env::temp_dir().join(format!( - "kres-find-pattern-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("kres-find-pattern-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("report.md"), b"").unwrap(); std::fs::write(tmp.join("other.md"), b"").unwrap(); @@ -867,10 +860,7 @@ mod tests { let allow: std::collections::BTreeSet = ["find"].iter().map(|s| s.to_string()).collect(); let (out, _) = dispatch_non_mcp(&tmp, &action, &allow).await; - assert!( - out.contains("report.md"), - "output missing report.md: {out}" - ); + assert!(out.contains("report.md"), "output missing report.md: {out}"); assert!( !out.contains("other.md"), "filter not applied, got other.md: {out}" @@ -886,17 +876,11 @@ mod tests { // content in the turn-log text — the bytes were going into // the symbol pool only. Model gave up and used `bash sed`. // The turn-log text must carry the content inline. - let tmp = std::env::temp_dir().join(format!( - "kres-read-text-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("kres-read-text-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); - let body = (1..=10) - .map(|n| format!("line {n}\n")) - .collect::(); + let body = (1..=10).map(|n| format!("line {n}\n")).collect::(); std::fs::write(tmp.join("f.txt"), body).unwrap(); - let action = - json!({"type":"read","file":"f.txt","line":3,"end_line":5}); + let action = json!({"type":"read","file":"f.txt","line":3,"end_line":5}); let allow: std::collections::BTreeSet = ["read"].iter().map(|s| s.to_string()).collect(); let (text, _sym) = dispatch_non_mcp(&tmp, &action, &allow).await; @@ -911,17 +895,11 @@ mod tests { async fn read_accepts_end_line_snake_case() { // The main-agent prompt advertises `end_line` as the canonical // arg name, but the dispatcher used to only look up `endLine`. - let tmp = std::env::temp_dir().join(format!( - "kres-read-snake-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("kres-read-snake-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); - let body = (1..=10) - .map(|n| format!("line {n}\n")) - .collect::(); + let body = (1..=10).map(|n| format!("line {n}\n")).collect::(); std::fs::write(tmp.join("f.txt"), body).unwrap(); - let action = - json!({"type":"read","file":"f.txt","line":3,"end_line":5}); + let action = json!({"type":"read","file":"f.txt","line":3,"end_line":5}); let allow: std::collections::BTreeSet = ["read"].iter().map(|s| s.to_string()).collect(); let (out, sym) = dispatch_non_mcp(&tmp, &action, &allow).await; @@ -947,13 +925,9 @@ mod tests { // With a non-empty allowlist that excludes "bash", a bash // action must bounce with an error that names the allowed // set and points at --allow / settings.json. - let tmp = std::env::temp_dir().join(format!( - "kres-gate-bash-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("kres-gate-bash-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); - let action = - json!({"type":"bash","command":"echo should not run > /tmp/gated"}); + let action = json!({"type":"bash","command":"echo should not run > /tmp/gated"}); let mut allow = std::collections::BTreeSet::new(); allow.insert("read".to_string()); allow.insert("grep".to_string()); @@ -964,8 +938,14 @@ mod tests { out.contains("'bash' is not in the allowed-action list"), "error missing action name: {out}" ); - assert!(out.contains("--allow bash"), "error missing fix hint: {out}"); - assert!(out.contains("settings.json"), "error missing settings hint: {out}"); + assert!( + out.contains("--allow bash"), + "error missing fix hint: {out}" + ); + assert!( + out.contains("settings.json"), + "error missing settings hint: {out}" + ); std::fs::remove_dir_all(&tmp).ok(); } @@ -976,10 +956,7 @@ mod tests { // settings.json semantic. Previously the dispatcher // short-circuited on is_empty() and allowed everything, // which silently neutered an operator's lockdown. - let tmp = std::env::temp_dir().join(format!( - "kres-gate-empty-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("kres-gate-empty-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("f.txt"), "hello\n").unwrap(); let action = json!({"type":"read","file":"f.txt"}); @@ -997,10 +974,7 @@ mod tests { ); // The file we wrote should remain unread (the dispatcher // bailed before touching it). - assert!( - !out.contains("hello"), - "read tool ran despite deny: {out}" - ); + assert!(!out.contains("hello"), "read tool ran despite deny: {out}"); std::fs::remove_dir_all(&tmp).ok(); } @@ -1009,10 +983,7 @@ mod tests { // An action with no `type` field should hit the existing // "unknown action type" error, NOT the allowlist-gate error. // A malformed action is not a gated action. - let tmp = std::env::temp_dir().join(format!( - "kres-malformed-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("kres-malformed-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); let action = json!({"command": "nope"}); // no `type` field let allow: std::collections::BTreeSet = diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index 9befc91..207ce90 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -461,8 +461,7 @@ impl Orchestrator { // for it — looping would just re-ask the same question. // Break out to the slow agent, which can surface the // question to the operator via its own followups. - if !parsed.followups.is_empty() - && parsed.followups.iter().all(|f| f.kind == "question") + if !parsed.followups.is_empty() && parsed.followups.iter().all(|f| f.kind == "question") { kres_core::async_eprintln!( "[fast round {}] only type:question followups — breaking to slow", @@ -729,11 +728,9 @@ impl Orchestrator { kres_core::TaskMode::Analysis | kres_core::TaskMode::Generic => { (slow_parsed.findings, Vec::new(), Vec::new()) } - kres_core::TaskMode::Coding => ( - Vec::new(), - slow_parsed.code_output, - slow_parsed.code_edits, - ), + kres_core::TaskMode::Coding => { + (Vec::new(), slow_parsed.code_output, slow_parsed.code_edits) + } }; // Only surface a slow-agent plan rewrite when this task is // the first slow call for the top-level prompt. Later @@ -1161,8 +1158,7 @@ impl Orchestrator { // for any of them — spinning another main-agent round // just burns tokens while the fast agent re-asks. Break // and let the slow/lens path surface the questions. - if !parsed.followups.is_empty() - && parsed.followups.iter().all(|f| f.kind == "question") + if !parsed.followups.is_empty() && parsed.followups.iter().all(|f| f.kind == "question") { kres_core::async_eprintln!( "[fast gather round {}] only type:question followups — breaking", @@ -1412,10 +1408,8 @@ mod tests { /// the lens slow agents that read `live_skills`) see it. #[test] fn apply_skill_reads_inserts_file_into_first_skill() { - let dir = std::env::temp_dir().join(format!( - "kres-apply-skill-reads-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("kres-apply-skill-reads-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let p = dir.join("skill.md"); std::fs::write(&p, "hello skill body").unwrap(); diff --git a/kres-agents/src/response.rs b/kres-agents/src/response.rs index d47130d..87f4726 100644 --- a/kres-agents/src/response.rs +++ b/kres-agents/src/response.rs @@ -218,8 +218,7 @@ fn value_to_plan(v: Value) -> Option { // the LLM stuffs in (prompt, goal, mode, created_at) // are ignored. An empty-steps rewrite is indistinguish- // able from "no rewrite", so drop it. - let rewrite: kres_core::PlanRewrite = - serde_json::from_value(other).ok()?; + let rewrite: kres_core::PlanRewrite = serde_json::from_value(other).ok()?; if rewrite.steps.is_empty() { None } else { diff --git a/kres-agents/src/todo_agent.rs b/kres-agents/src/todo_agent.rs index 6179698..7a2021e 100644 --- a/kres-agents/src/todo_agent.rs +++ b/kres-agents/src/todo_agent.rs @@ -790,9 +790,7 @@ fn build_instructions(has_lenses: bool, has_plan: bool) -> String { /// carried a parseable `todo` field; returns `None` when the /// envelope itself couldn't be parsed (callers fall back to the /// todo-only parser, which tries harder on malformed replies). -fn parse_todo_update_full( - text: &str, -) -> Option<(Vec, Option)> { +fn parse_todo_update_full(text: &str) -> Option<(Vec, Option)> { if let Ok(r) = serde_json::from_str::(text) { if let Some(items) = todo_list_from_value(r.todo) { return Some((items, r.plan)); @@ -814,9 +812,7 @@ fn parse_todo_update_full( depth -= 1; if depth == 0 { if let Some(s) = start.take() { - if let Ok(r) = - serde_json::from_str::(&text[s..=i]) - { + if let Ok(r) = serde_json::from_str::(&text[s..=i]) { if let Some(items) = todo_list_from_value(r.todo) { return Some((items, r.plan)); } diff --git a/kres-agents/src/tools.rs b/kres-agents/src/tools.rs index a1d3255..cd3427f 100644 --- a/kres-agents/src/tools.rs +++ b/kres-agents/src/tools.rs @@ -298,8 +298,7 @@ pub async fn bash_run(workspace: &Path, args: &BashArgs) -> Result Result Result Option { - lookup_with_root(dirs::home_dir().map(|h| h.join(".kres").join("commands")), name) + lookup_with_root( + dirs::home_dir().map(|h| h.join(".kres").join("commands")), + name, + ) } /// Testable core of `lookup`. `commands_dir` is the directory to @@ -59,10 +62,7 @@ pub fn lookup(name: &str) -> Option { /// entirely — useful in tests that want to pin the embedded /// fallback). `name` is validated against the same character set /// as the public `lookup`. -pub fn lookup_with_root( - commands_dir: Option, - name: &str, -) -> Option { +pub fn lookup_with_root(commands_dir: Option, name: &str) -> Option { if !is_valid_name(name) { return None; } @@ -122,10 +122,7 @@ mod tests { fn every_embedded_body_is_non_empty() { for name in embedded_names() { let body = lookup(name).unwrap_or_default(); - assert!( - !body.trim().is_empty(), - "command {name} body is empty" - ); + assert!(!body.trim().is_empty(), "command {name} body is empty"); } } @@ -163,15 +160,10 @@ mod tests { // Drop a file at /commands/review.md and assert // lookup_with_root returns its contents, not the embedded // review template. - let dir = std::env::temp_dir().join(format!( - "kres-cmd-override-{}", - std::process::id() - )); + let dir = std::env::temp_dir().join(format!("kres-cmd-override-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("review.md"), "OPERATOR REVIEW OVERRIDE") - .unwrap(); - let got = lookup_with_root(Some(dir.clone()), "review") - .expect("override should resolve"); + std::fs::write(dir.join("review.md"), "OPERATOR REVIEW OVERRIDE").unwrap(); + let got = lookup_with_root(Some(dir.clone()), "review").expect("override should resolve"); assert_eq!(got, "OPERATOR REVIEW OVERRIDE"); std::fs::remove_dir_all(&dir).ok(); } @@ -182,14 +174,12 @@ mod tests { // embedded copy (consistent with the agent-prompt loader's // behaviour) — returning empty prompt text would brick the // command silently. - let dir = std::env::temp_dir().join(format!( - "kres-cmd-empty-override-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("kres-cmd-empty-override-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("review.md"), " \n\t\n").unwrap(); - let got = lookup_with_root(Some(dir.clone()), "review") - .expect("should fall through to embedded"); + let got = + lookup_with_root(Some(dir.clone()), "review").expect("should fall through to embedded"); assert!(got.contains("[investigate]"), "got {got:?}"); std::fs::remove_dir_all(&dir).ok(); } diff --git a/kres-core/src/consent.rs b/kres-core/src/consent.rs index c0f12d1..8e2c57b 100644 --- a/kres-core/src/consent.rs +++ b/kres-core/src/consent.rs @@ -153,20 +153,8 @@ pub fn grant_paths_from_text(store: &ConsentStore, cwd: &Path, text: &str) -> Ve fn is_suspicious_grant(dir: &Path) -> bool { // Bare-tree exact matches. let suspicious_exact: &[&str] = &[ - "/usr", - "/etc", - "/var", - "/opt", - "/lib", - "/lib64", - "/bin", - "/sbin", - "/boot", - "/srv", - "/sys", - "/proc", - "/root", - "/home", + "/usr", "/etc", "/var", "/opt", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/srv", "/sys", + "/proc", "/root", "/home", ]; if let Some(s) = dir.to_str() { if suspicious_exact.contains(&s) { @@ -191,26 +179,10 @@ fn strip_token_punctuation(s: &str) -> &str { // dots of `./foo` and `../foo`, turning a relative path into an // absolute one that doesn't exist — so left-trim is limited to // chars that can never legitimately start a path. - let left_trimmed = s.trim_start_matches(|c: char| { - matches!(c, '`' | '(' | '[' | '{' | '\'' | '"' | '<') - }); - left_trimmed.trim_end_matches(|c: char| { - matches!( - c, - ',' | '.' - | ':' - | ';' - | '!' - | '?' - | '`' - | ')' - | ']' - | '}' - | '\'' - | '"' - | '>' - ) - }) + let left_trimmed = s.trim_start_matches(['`', '(', '[', '{', '\'', '"', '<']); + left_trimmed.trim_end_matches([ + ',', '.', ':', ';', '!', '?', '`', ')', ']', '}', '\'', '"', '>', + ]) } fn looks_like_path(s: &str) -> bool { @@ -408,7 +380,9 @@ mod tests { #[test] fn looks_like_path_skips_url_schemes() { assert!(!looks_like_path("http://example.com/foo")); - assert!(!looks_like_path("https://github.com/masoncl/review-prompts")); + assert!(!looks_like_path( + "https://github.com/masoncl/review-prompts" + )); assert!(!looks_like_path("s3://bucket/key")); assert!(!looks_like_path("ftp://host/p")); // But a path with a colon in a non-scheme position still diff --git a/kres-core/src/mode.rs b/kres-core/src/mode.rs index b787d80..f94dac8 100644 --- a/kres-core/src/mode.rs +++ b/kres-core/src/mode.rs @@ -23,9 +23,10 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TaskMode { + #[default] Analysis, Generic, Coding, @@ -59,12 +60,6 @@ pub struct CodeEdit { pub replace_all: bool, } -impl Default for TaskMode { - fn default() -> Self { - Self::Analysis - } -} - impl TaskMode { pub fn as_str(self) -> &'static str { match self { diff --git a/kres-core/src/plan.rs b/kres-core/src/plan.rs index 0f96a5a..566b56d 100644 --- a/kres-core/src/plan.rs +++ b/kres-core/src/plan.rs @@ -278,17 +278,17 @@ impl Plan { } } for tid in &step.todo_ids { - if let Some(n) = todo.iter().position(|i| { - (!i.id.is_empty() && i.id == *tid) || i.name == *tid - }) { + if let Some(n) = todo + .iter() + .position(|i| (!i.id.is_empty() && i.id == *tid) || i.name == *tid) + { linked_idx.insert(n); } } if linked_idx.is_empty() { continue; } - let linked: Vec<&crate::TodoItem> = - linked_idx.iter().map(|n| &todo[*n]).collect(); + let linked: Vec<&crate::TodoItem> = linked_idx.iter().map(|n| &todo[*n]).collect(); let all_terminal = linked.iter().all(|i| { matches!( i.status, @@ -442,10 +442,7 @@ mod tests { #[test] fn normalize_steps_filters_empty_titles() { - let out = normalize_steps(vec![ - step("keep-id", ""), - step("", "Kept title"), - ]); + let out = normalize_steps(vec![step("keep-id", ""), step("", "Kept title")]); assert_eq!(out.len(), 1); assert_eq!(out[0].id, "kept-title"); } diff --git a/kres-core/src/task.rs b/kres-core/src/task.rs index 89740d4..cbf653f 100644 --- a/kres-core/src/task.rs +++ b/kres-core/src/task.rs @@ -963,10 +963,8 @@ mod tests { #[tokio::test] async fn empty_analysis_does_not_increment_turn_counter() { let mgr = TaskManager::new(); - mgr.spawn("t-empty", None, |_h| async { - Ok(TaskOutcome::default()) - }) - .await; + mgr.spawn("t-empty", None, |_h| async { Ok(TaskOutcome::default()) }) + .await; loop { let s = mgr.snapshot().await; if s.iter().all(|t| t.state.is_terminal()) { diff --git a/kres-repl/src/settings.rs b/kres-repl/src/settings.rs index 1db9a4e..a6e0a07 100644 --- a/kres-repl/src/settings.rs +++ b/kres-repl/src/settings.rs @@ -55,8 +55,7 @@ use kres_llm::Model; /// (`bash sed` for range reads, `bash find` for file locates). /// Coding flows that genuinely need `cc && ./repro` can opt in via /// `--allow bash` or via settings.actions.allowed. -pub const DEFAULT_ALLOWED_ACTIONS: &[&str] = - &["grep", "find", "read", "git", "edit"]; +pub const DEFAULT_ALLOWED_ACTIONS: &[&str] = &["grep", "find", "read", "git", "edit"]; /// Every action type the main agent might emit. Used for typo /// detection when an operator writes `--allow bsah` or sticks @@ -67,8 +66,7 @@ pub const DEFAULT_ALLOWED_ACTIONS: &[&str] = /// gate in dispatch_non_mcp never consults the `"mcp"` entry (MCP /// actions are gated by mcp.json server registration, not this /// list), so including it here is effectively documentation. -pub const KNOWN_ACTION_TYPES: &[&str] = - &["grep", "find", "read", "git", "edit", "bash", "mcp"]; +pub const KNOWN_ACTION_TYPES: &[&str] = &["grep", "find", "read", "git", "edit", "bash", "mcp"]; #[derive(Debug, Clone, Deserialize, Default)] pub struct Settings { @@ -151,6 +149,7 @@ impl Settings { /// project's None leaves the global value in place. /// - `actions.allowed`: project's Some REPLACES global's Some /// (allowlists don't union — the more specific config wins). + /// /// A missing project settings file is not an error. pub fn load_merged(project_root: &Path) -> Self { let proj_path = project_root.join(".kres").join("settings.json"); @@ -162,10 +161,7 @@ impl Settings { /// `project` is the path to the per-project overrides. Public /// so the test suite can exercise the merge without having to /// mock the operator's real home directory. - pub fn load_merged_with_paths( - global: Option<&Path>, - project: &Path, - ) -> Self { + pub fn load_merged_with_paths(global: Option<&Path>, project: &Path) -> Self { let mut s = match global { Some(p) => Self::load_from(p), None => Self::default(), @@ -257,10 +253,7 @@ impl Settings { /// - `"all"` expands to the full built-in set plus `bash` /// (every action the dispatcher knows). Useful for one-off /// runs where the operator wants a total escape hatch. - pub fn effective_allowed_actions( - &self, - cli_extras: &[String], - ) -> BTreeSet { + pub fn effective_allowed_actions(&self, cli_extras: &[String]) -> BTreeSet { let known: BTreeSet<&str> = KNOWN_ACTION_TYPES.iter().copied().collect(); let mut out: BTreeSet = match &self.actions.allowed { Some(list) => list @@ -345,9 +338,7 @@ fn levenshtein(a: &str, b: &str) -> usize { cur[0] = i; for j in 1..=m { let cost = if av[i - 1] == bv[j - 1] { 0 } else { 1 }; - cur[j] = (prev[j] + 1) - .min(cur[j - 1] + 1) - .min(prev[j - 1] + cost); + cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost); } std::mem::swap(&mut prev, &mut cur); } @@ -489,10 +480,8 @@ mod tests { // produce exactly that set — no defaults leaking through. // Uses load_from directly rather than load_merged to avoid // touching the operator's real ~/.kres/settings.json. - let dir = std::env::temp_dir().join(format!( - "kres-settings-proj-only-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("kres-settings-proj-only-{}", std::process::id())); let proj = dir.join(".kres"); std::fs::create_dir_all(&proj).unwrap(); std::fs::write( @@ -516,10 +505,8 @@ mod tests { // main=sonnet. Result: allowlist={read} (project wins), // slow=opus (project didn't touch), main=sonnet (project // wins). - let dir = std::env::temp_dir().join(format!( - "kres-settings-merge-real-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("kres-settings-merge-real-{}", std::process::id())); let global_dir = dir.join("global"); let proj_dir = dir.join("project").join(".kres"); std::fs::create_dir_all(&global_dir).unwrap(); @@ -536,8 +523,7 @@ mod tests { r#"{"models":{"main":"claude-sonnet-4-6"},"actions":{"allowed":["read"]}}"#, ) .unwrap(); - let s = - Settings::load_merged_with_paths(Some(&global_path), &proj_path); + let s = Settings::load_merged_with_paths(Some(&global_path), &proj_path); assert_eq!(s.models.slow.as_deref(), Some("claude-opus-4-7")); assert_eq!(s.models.main.as_deref(), Some("claude-sonnet-4-6")); assert_eq!( @@ -560,15 +546,10 @@ mod tests { )); std::fs::create_dir_all(&dir).unwrap(); let global = dir.join("global.json"); - std::fs::write(&global, r#"{"actions":{"allowed":["read","grep"]}}"#) - .unwrap(); + std::fs::write(&global, r#"{"actions":{"allowed":["read","grep"]}}"#).unwrap(); let missing_project = dir.join("nope/.kres/settings.json"); - let s = Settings::load_merged_with_paths( - Some(&global), - &missing_project, - ); - let a: Vec = - s.effective_allowed_actions(&[]).iter().cloned().collect(); + let s = Settings::load_merged_with_paths(Some(&global), &missing_project); + let a: Vec = s.effective_allowed_actions(&[]).iter().cloned().collect(); assert_eq!(a, vec!["grep".to_string(), "read".to_string()]); std::fs::remove_dir_all(&dir).ok(); } From 1c461d931451dbd5cba9a4a0b4317a46c17ca413 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 16:30:30 -0700 Subject: [PATCH 35/76] summary: rename output to summary.{txt,md}, stage oversized prompts The /summary and /summary-markdown commands were hard-coded to write bug-report.txt / bug-report.md. The template itself is bug-report flavoured by default, but the summariser is general enough to run against any prompt, and the output filename should not force the operator to rename it. Rename the default output to summary.txt (plain text) and summary.md (markdown), matching the template names already under ~/.kres/commands/. Update the REPL help, docs, and CLAUDE.md alongside. Replace the --summary --markdown two-flag combo with a single --summary-markdown flag. Selecting the markdown variant and selecting standalone summary mode were always paired, so a single flag is clearer and removes the ignored-when-not-summary --markdown shape. The REPL's /summary-markdown was already a distinct command; the CLI now matches. Fold a map-reduce staging path into run_summary. A large research run's report.md + findings.json can exceed the fast agent's max_input_tokens, in which case kres previously issued a single oversize call and relied on the rate-limit retry path to shrink. Now run_summary sizes the assembled payload up front, and when it overflows, splits the findings into contiguous chunks that each fit, renders one partial summary per chunk, then runs a combine call with a synthesised system prompt that merges the partials without duplication. The single-call path remains the default when the payload fits. Keep the synthesised partial_note and combine_system_prompt template-agnostic ("partial summary", "merging partial summaries into a single summary") so an operator-provided non-bug-report template at ~/.kres/commands/summary.md is not overridden by bug-report wording whenever staging fires. The JSON task field is "summary" rather than "bug_report" for the same reason. Add a cheap chars/4 sizing gate (size_call) so small runs skip the count_tokens_exact round-trip; only payloads close to budget pay for the exact count. Pre-size the combine call the same way and error with a descriptive message when the concatenation overflows the input budget. chunk_findings_to_fit starts at parts=2 (stage_summary only invokes it after the 1-chunk payload already oversized) and sizes each probe with the same partial_note the real call will use, so the budget check matches within a few bytes. Signed-off-by: Chris Mason --- CLAUDE.md | 6 +- README.md | 4 +- docs/cli.md | 10 +- docs/commands.md | 14 +- docs/review-template.md | 2 +- docs/summary.md | 24 +- docs/turns-and-follow.md | 4 +- kres-core/src/mode.rs | 2 +- kres-repl/src/commands.rs | 9 +- kres-repl/src/session.rs | 256 ++++++++------------ kres-repl/src/summary.rs | 491 ++++++++++++++++++++++++++++++++------ kres/src/main.rs | 120 ++++------ 12 files changed, 607 insertions(+), 335 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9851d1c..ea8bce0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,8 +117,8 @@ Rate limiters are shared across agents that use the same API key string. | `/resume [PATH]` | Load a persisted `session.json` (defaults to `/session.json.prev` → live file). Overwrites in-memory state | | `/todo --clear` | Clear all todo items | | `/cost` | Token usage by agent role and model | -| `/summary [FILE]` | Fast agent renders the run's report.md + findings.json into a bug report via the embedded `summary` slash-command template. Output defaults to `bug-report.txt` in the results dir | -| `/summary-markdown [FILE]` | Same as `/summary` but uses the `summary-markdown` template and defaults the filename to `bug-report.md` | +| `/summary [FILE]` | Fast agent renders the run's report.md + findings.json into a summary via the embedded `summary` slash-command template. Output defaults to `summary.txt` in the results dir. Auto-chunks findings when the prompt exceeds the fast agent's `max_input_tokens` and runs a combine pass to merge the partials | +| `/summary-markdown [FILE]` | Same as `/summary` but uses the `summary-markdown` template and defaults the filename to `summary.md` | | `/review ` | Compose the embedded `review` slash-command template with `` and submit as a new task — CLI equivalent of `--prompt 'review: '` | | `/report ` | Write all findings to markdown file | | `/followup` | Show deferred items (identified but skipped when goal met) | @@ -183,7 +183,7 @@ Rate limiters are shared across agents that use the same API key string. findings.json # Cumulative findings (history in findings-N.json) report.md # Append-only narrative session.json # Plan + todo + deferred + counters (resume state) - bug-report.txt # Output of /summary or kres --summary + summary.txt # Output of /summary or kres --summary (summary.md with --summary-markdown) .kres/logs// # Next to cwd, one dir per REPL run code.jsonl # All fast + slow agent turns diff --git a/README.md b/README.md index 6011812..998bc3c 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ parallel-lens review. `--prompt 'review: X'` invokes the embedded review template — a five-lens parallel audit over the target. `--results DIR` keeps the run's artifacts under `DIR/` (findings.json, - report.md, bug-report.txt). `--turns 2` stops after two + report.md, summary.txt). `--turns 2` stops after two completed tasks; see [docs/turns-and-follow.md](docs/turns-and-follow.md) for the other stop modes. @@ -84,7 +84,7 @@ configured via `setup.sh` flags — see - [docs/coding-tasks.md](docs/coding-tasks.md) — reproducer and fix generation (`code_output`, `code_edits`, `bash` verify). - [docs/summary.md](docs/summary.md) — `/summary`, - `kres --summary`, and the bug-report output format. + `kres --summary`, and the summary output format. - [docs/turns-and-follow.md](docs/turns-and-follow.md) — when kres decides a non-interactive run is done. - [docs/action-allowlist.md](docs/action-allowlist.md) — which diff --git a/docs/cli.md b/docs/cli.md index cb4fc62..8755ac7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -11,7 +11,7 @@ kres [--fast-agent ...] [--slow TAG | --slow-agent ...] [--main-agent ...] [--prompt PROMPT] [--template PATH] [--turns N] [--follow] [--resume] [--gather-turns N] [--stop-grace-ms MS] [--stdio] - [--allow ACTION]... [--summary] [--markdown] + [--allow ACTION]... [--summary | --summary-markdown] ``` Pass `kres --help` for the full list with argument-by-argument @@ -23,8 +23,8 @@ Related docs: `--turns 0`, `--follow`, stagnation cap. - [action-allowlist.md](action-allowlist.md) — `--allow ACTION` and the dispatcher's non-MCP allowlist. -- [summary.md](summary.md) — `--summary`, `--template`, - `--markdown`. +- [summary.md](summary.md) — `--summary`, + `--summary-markdown`, `--template`. - [configuration.md](configuration.md) — model-id overrides (`--fast-model`, `--slow-model`, `--main-model`, `--todo-model`). @@ -44,8 +44,8 @@ Related docs: | `/plan` | Show the current plan + per-step status | | `/resume [PATH]` | Load a persisted `session.json` | | `/followup` | List items deferred by goal-met or `--turns` cap | -| `/summary [FILE]` | Render `report.md` + `findings.json` to a plain-text bug report | -| `/summary-markdown [FILE]` | Same as `/summary`, markdown output | +| `/summary [FILE]` | Render `report.md` + `findings.json` to a plain-text summary (default `summary.txt`) | +| `/summary-markdown [FILE]` | Same as `/summary`, markdown output (default `summary.md`) | | `/review ` | Compose the review template + target, submit | | `/extract …` | Copy artifacts out (`--dir`, `--report`, `--todo`, `--findings`) | | `/done N` | Remove the N'th pending todo | diff --git a/docs/commands.md b/docs/commands.md index d879f91..b35ee10 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,7 +10,7 @@ override or add commands by dropping a file at |--------------------|------------------------------------------------------------------------------------------|--------------------------------| | `review` | `kres --prompt 'review: fs/btrfs/ctree.c'` or `kres --prompt '/review fs/btrfs/ctree.c'` | `/review fs/btrfs/ctree.c` | | `summary` | `kres --summary --results DIR` | `/summary [filename]` | -| `summary-markdown` | `kres --summary --markdown --results DIR` | `/summary-markdown [filename]` | +| `summary-markdown` | `kres --summary-markdown --results DIR` | `/summary-markdown [filename]` | The three shipped templates play two different roles: @@ -20,10 +20,14 @@ The three shipped templates play two different roles: (see [review-template.md](review-template.md)). - `summary` — a system prompt. `/summary` and `kres --summary` feed the template body to the fast agent alongside the run's - `report.md` + `findings.json` to render `bug-report.txt` - (`kres-repl/src/summary.rs`). No target composition. -- `summary-markdown` — identical path; selected by `--markdown` - and writes `bug-report.md`. + `report.md` + `findings.json` to render `summary.txt` + (`kres-repl/src/summary.rs`). When the assembled prompt + exceeds the fast agent's `max_input_tokens`, findings are + split across partial summaries that a final combine call + merges. No target composition. +- `summary-markdown` — identical path; selected by + `--summary-markdown` (or `/summary-markdown`) and writes + `summary.md`. Adding your own: drop `~/.kres/commands/audit.md` and run `kres --prompt 'audit: net/...'` or `/audit net/...`. No rebuild diff --git a/docs/review-template.md b/docs/review-template.md index 21c0b48..4a2fe48 100644 --- a/docs/review-template.md +++ b/docs/review-template.md @@ -53,6 +53,6 @@ adds a `/` slash-command invocable via `--prompt ": target"` or `--prompt "/ target"`. `--results ` keeps the run's artifacts (`findings.json` -plus `findings-N.json` history, `report.md`, `bug-report.txt`) +plus `findings-N.json` history, `report.md`, `summary.txt`) in `/`; without it kres picks `~/.kres/sessions//`. diff --git a/docs/summary.md b/docs/summary.md index 4d78a4a..58ed572 100644 --- a/docs/summary.md +++ b/docs/summary.md @@ -1,26 +1,34 @@ -# Summary output — `/summary`, `--summary`, `bug-report.txt` +# Summary output — `/summary`, `--summary`, `summary.txt`/`summary.md` After each task, kres appends the slow agent's narrative to `/report.md` and rewrites `/findings.json` with the cumulative merged list (the previous canonical file is copied to `findings-N.json` first, preserving history). -A plain-text bug report is produced by `/summary` (or -automatically on `--turns` exit, or standalone via -`kres --summary --results `). That run: +A plain-text summary is produced by `/summary` (or automatically +on `--turns` exit, or standalone via +`kres --summary --results `). The markdown variant is +`/summary-markdown` / `kres --summary-markdown --results `, +which writes `summary.md`. That run: - reads `/prompt.md` (saved on first submit so later summaries know the original question), `/report.md`, and `/findings.json`; - calls the fast agent with the embedded `summary` slash-command template as its system prompt (override at - `~/.kres/commands/summary.md`; `--markdown` picks the - `summary-markdown` variant); + `~/.kres/commands/summary.md`; `--summary-markdown` picks the + `summary-markdown` variant at + `~/.kres/commands/summary-markdown.md`); +- if the assembled prompt exceeds the fast agent's + `max_input_tokens`, splits the findings into chunks that each + fit, renders one partial summary per chunk, and then runs a + final combine call that merges the partials into one report; - orders sections by `bug-severity` (`high` → `medium` → `low` → `latent` → `unknown`), one section per bug headed by `Subject:`, `bug-severity:`, `bug-impact:` lines; -- writes `/bug-report.txt` (or `bug-report.txt` in cwd - when `--results` was absent). +- writes `/summary.txt` (or `summary.md` with + `--summary-markdown`); falls back to the cwd when `--results` + was absent. `--template PATH` overrides the shipped summariser prompt for one run without rebuilding. diff --git a/docs/turns-and-follow.md b/docs/turns-and-follow.md index d2ebb69..9fc7496 100644 --- a/docs/turns-and-follow.md +++ b/docs/turns-and-follow.md @@ -25,8 +25,8 @@ fast → main → slow and produced non-empty analysis or code output On any `--turns` exit — run-count cap, goal-met drain, or stagnation — kres cancels in-flight work, auto-runs `/summary` -(`bug-report.txt`, or `bug-report.md` with `--markdown`) in the -results dir (cwd when `--results` was absent), and exits. +(`summary.txt`; use `--summary-markdown` to get `summary.md`) in +the results dir (cwd when `--results` was absent), and exits. Remaining pending / blocked todos move to the deferred list; `/followup` lists them and `/continue` dispatches them if you re-enter the REPL. diff --git a/kres-core/src/mode.rs b/kres-core/src/mode.rs index f94dac8..ab7228d 100644 --- a/kres-core/src/mode.rs +++ b/kres-core/src/mode.rs @@ -70,7 +70,7 @@ impl TaskMode { } /// True for modes that feed the findings pipeline (findings merger - /// runs, /summary bug-report is meaningful). Coding tasks produce + /// runs, /summary output is meaningful). Coding tasks produce /// files instead of findings, so they return false. pub fn produces_findings(self) -> bool { matches!(self, Self::Analysis | Self::Generic) diff --git a/kres-repl/src/commands.rs b/kres-repl/src/commands.rs index 8fc4576..59bf3fd 100644 --- a/kres-repl/src/commands.rs +++ b/kres-repl/src/commands.rs @@ -41,14 +41,13 @@ pub enum Command { /// `/followup` — list items deferred by goal-met or --turns cap. Followup, /// `/summary [filename]` — render the run's report.md + - /// findings.json into a plain-text bug report. Filename defaults - /// to bug-report.txt, placed in the results directory when one - /// was configured (else the current working directory). + /// findings.json into a plain-text summary. Filename defaults to + /// `summary.txt`, placed in the results directory when one was + /// configured (else the current working directory). Summary { filename: Option }, /// `/summary-markdown [filename]` — same as /summary but /// selects the `summary-markdown` slash-command template for - /// the system prompt and defaults the filename to - /// `bug-report.md`. + /// the system prompt and defaults the filename to `summary.md`. SummaryMarkdown { filename: Option }, /// `/review ` — submit a prompt equivalent to /// `--prompt "review: "`. Composes the `review` diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 6f863f7..6fbce80 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -33,6 +33,7 @@ pub struct ReplConfig { /// * `true`: also accept 3 consecutive analysis-producing runs /// with no new findings as a stop condition — a cost cap /// for when the goal agent stays stubbornly "not met". + /// /// No effect when `turns_limit > 0`: the run-count cap still /// wins there. pub follow_followups: bool, @@ -225,12 +226,12 @@ pub struct Session { /// Set to true by the reaper when the --turns cap is reached. /// The main REPL loop checks this after root_shutdown breaks the /// select; when true, /summary is invoked before teardown so the - /// operator gets a bug-report.txt on a clean --turns N run. + /// operator gets a summary.txt on a clean --turns N run. turns_exhausted: Arc, /// True once any task has run in Coding mode during this session. - /// Suppresses the teardown bug-report summary — coding-mode - /// sessions don't have findings to summarise and the summary - /// template would produce gibberish. + /// Suppresses the teardown summary — coding-mode sessions don't + /// have findings to summarise and the summary template would + /// produce gibberish. any_coding_task: Arc, /// Set by `/stop`; cleared by `submit_prompt` and `/continue`. /// While set, the idle-loop auto-continue does not fire. Without @@ -422,7 +423,7 @@ impl Session { deferred: Arc::new(tokio::sync::Mutex::new(Vec::new())), interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), last_prompt: Arc::new(tokio::sync::Mutex::new(None)), - persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), + persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -544,7 +545,8 @@ impl Session { /// resume (no persist path or file absent), and `Err` on parse /// / I/O failure. pub async fn resume_state(&self) -> Result> { - self.resume_state_from(self.cfg.persist_path.as_deref()).await + self.resume_state_from(self.cfg.persist_path.as_deref()) + .await } /// `resume_state` with an explicit source path override. `None` @@ -665,9 +667,7 @@ impl Session { // would re-install the scroll region behind the // child's back, and paint() would scribble // across the child's frame. - if status_paused_for_paint - .load(std::sync::atomic::Ordering::Acquire) - { + if status_paused_for_paint.load(std::sync::atomic::Ordering::Acquire) { continue; } ticks_since_size_check += 1; @@ -837,27 +837,16 @@ impl Session { // folded into effective_analysis so failures are // visible to the next slow-agent turn, the goal // agent, and /summary (not just stderr). - if matches!(r.mode, kres_core::TaskMode::Coding) - && !r.code_output.is_empty() - { - persist_code_output( - &code_output_root_for_reaper, - &r.name, - &r.code_output, - ) - .await; + if matches!(r.mode, kres_core::TaskMode::Coding) && !r.code_output.is_empty() { + persist_code_output(&code_output_root_for_reaper, &r.name, &r.code_output) + .await; } let applied_edits: Vec = if matches!( r.mode, kres_core::TaskMode::Coding ) && !r.code_edits.is_empty() { - apply_code_edits( - &code_output_root_for_reaper, - &r.name, - &r.code_edits, - ) - .await + apply_code_edits(&code_output_root_for_reaper, &r.name, &r.code_edits).await } else { Vec::new() }; @@ -871,8 +860,7 @@ impl Session { // sitting on disk (session 597b4bf7). Append a // short trailer listing what landed so the goal // agent has concrete evidence to judge on. - let effective_analysis = if r.code_output.is_empty() - && applied_edits.is_empty() + let effective_analysis = if r.code_output.is_empty() && applied_edits.is_empty() { r.analysis.clone() } else { @@ -883,18 +871,11 @@ impl Session { if !r.code_output.is_empty() { s.push_str("\n---\nFiles written to workspace:\n"); for f in &r.code_output { - let purpose = if f.purpose.is_empty() { - "" - } else { - &f.purpose - }; + let purpose = if f.purpose.is_empty() { "" } else { &f.purpose }; if purpose.is_empty() { s.push_str(&format!("- {}\n", f.path)); } else { - s.push_str(&format!( - "- {} — {}\n", - f.path, purpose - )); + s.push_str(&format!("- {} — {}\n", f.path, purpose)); } // Include the head of the file so the // goal agent can see the actual script @@ -902,14 +883,11 @@ impl Session { // 2000 chars so a very long artifact // doesn't blow out the goal-check // token budget. - let head: String = - f.content.chars().take(2000).collect(); + let head: String = f.content.chars().take(2000).collect(); s.push_str("```\n"); s.push_str(&head); if f.content.chars().count() > 2000 { - s.push_str( - "\n… (truncated, full content at ", - ); + s.push_str("\n… (truncated, full content at "); s.push_str(&f.path); s.push_str(")\n"); } @@ -944,11 +922,9 @@ impl Session { // `_append_report` for an always-up-to-date // on-disk narrative. if let Some(ref rp) = report_path_for_reaper { - if let Err(e) = crate::report::append_task_section( - rp, - &r.name, - &effective_analysis, - ) { + if let Err(e) = + crate::report::append_task_section(rp, &r.name, &effective_analysis) + { tracing::warn!( target: "kres_repl", "report append to {}: {e}", @@ -972,8 +948,8 @@ impl Session { // would rack up API calls AND inject new todos // into the queue the operator just drained with // /stop, reproducing the "still going" feeling. - let stop_latched_now = stop_latched_for_reaper - .load(std::sync::atomic::Ordering::Acquire); + let stop_latched_now = + stop_latched_for_reaper.load(std::sync::atomic::Ordering::Acquire); if stop_latched_now { continue; } @@ -981,8 +957,7 @@ impl Session { // and Generic tasks — both feed the findings // pipeline. Coding tasks skip it: their output is // source files, not findings. - let had_delta = r.mode.produces_findings() - && !r.findings_delta.is_empty(); + let had_delta = r.mode.produces_findings() && !r.findings_delta.is_empty(); if had_delta { // §16: when a consolidator client is available // we reuse it as the findings merger too. @@ -1089,8 +1064,7 @@ impl Session { if grew { no_new_findings_streak = 0; } else { - no_new_findings_streak = - no_new_findings_streak.saturating_add(1); + no_new_findings_streak = no_new_findings_streak.saturating_add(1); } } if had_delta { @@ -1302,8 +1276,7 @@ impl Session { "[goal-not-met → todo update] injecting {} missing item(s) as question followups", missing_fus.len() ); - let plan_for_todo = - mgr_for_reaper.plan_snapshot().await; + let plan_for_todo = mgr_for_reaper.plan_snapshot().await; match kres_agents::update_todo_via_agent_with_logger( tc, &completed_query, @@ -1324,18 +1297,14 @@ impl Session { updated.todo.iter().filter(|t| t.status == kres_core::TodoStatus::Done).count(), ); if let Some(rewrite) = updated.plan { - let prior = - mgr_for_reaper.plan_snapshot().await; - let new_plan = - rewrite.apply_to(prior.as_ref()); + let prior = mgr_for_reaper.plan_snapshot().await; + let new_plan = rewrite.apply_to(prior.as_ref()); log_plan_change( "todo agent: plan rewrite (goal-not-met)", prior.as_ref(), &new_plan, ); - mgr_for_reaper - .set_plan(Some(new_plan)) - .await; + mgr_for_reaper.set_plan(Some(new_plan)).await; } mgr_for_reaper.replace_todo(updated.todo).await; } @@ -1436,16 +1405,14 @@ impl Session { .filter(|t| { matches!( t.status, - kres_core::TodoStatus::Pending - | kres_core::TodoStatus::Blocked + kres_core::TodoStatus::Pending | kres_core::TodoStatus::Blocked ) }) .count(); let followups_drained = active == 0 && pending_or_blocked == 0; let no_progress = no_new_findings_streak >= NO_NEW_FINDINGS_STOP; let goal_configured = goal_client_for_reaper.is_some(); - let no_goal_batch_stop = - !goal_configured && !follow_followups && active == 0; + let no_goal_batch_stop = !goal_configured && !follow_followups && active == 0; let should_stop = if follow_followups { followups_drained || no_progress } else if goal_configured { @@ -1531,8 +1498,7 @@ impl Session { // already set; that's fine — subsequent Sessions in the // same process (rare; tests) will see the first one's // store, which is acceptable for the unit-test surface. - let _ = - kres_core::consent::install(Arc::new(kres_core::ConsentStore::new())); + let _ = kres_core::consent::install(Arc::new(kres_core::ConsentStore::new())); print_banner(); if !self.lenses.is_empty() { println!( @@ -1617,9 +1583,7 @@ impl Session { Command::Resume { path } => self.cmd_resume(path).await, Command::Followup => self.cmd_followup().await, Command::Summary { filename } => self.cmd_summary(filename, false).await, - Command::SummaryMarkdown { filename } => { - self.cmd_summary(filename, true).await - } + Command::SummaryMarkdown { filename } => self.cmd_summary(filename, true).await, Command::Review { target } => self.cmd_review(target).await, Command::Extract { dir, @@ -1698,7 +1662,7 @@ impl Session { // --turns exit path: reaper flips turns_exhausted when the // slow-agent run count hits cfg.turns_limit, then cancels // root_shutdown to break the REPL loop above. On a clean - // --turns run, render a bug-report via /summary before + // --turns run, render a summary via /summary before // teardown so the operator gets the artifact without having // to run `kres --summary` afterwards. // @@ -1715,10 +1679,10 @@ impl Session { { if coding_session { kres_core::async_eprintln!( - "--turns: skipping bug-report summary (coding session — see / for emitted files)" + "--turns: skipping summary (coding session — see / for emitted files)" ); } else { - kres_core::async_eprintln!("--turns: rendering bug-report.txt before exit"); + kres_core::async_eprintln!("--turns: rendering summary.txt before exit"); self.cmd_summary(None, false).await; } } @@ -1797,11 +1761,8 @@ impl Session { // in its followups. if include_recent_context { if let Some(store) = kres_core::consent::get() { - let added = kres_core::consent::grant_paths_from_text( - &store, - &self.cfg.workspace, - &text, - ); + let added = + kres_core::consent::grant_paths_from_text(&store, &self.cfg.workspace, &text); if !added.is_empty() { let label: Vec = added.iter().map(|g| g.dir.display().to_string()).collect(); @@ -1889,7 +1850,7 @@ impl Session { }; // Latch the session-wide "coding session" flag as soon as any // task is submitted in coding mode. The teardown path reads - // this to suppress the bug-report summary — a coding session + // this to suppress the teardown /summary — a coding session // has no findings to summarise, and running the bug-summary // template over coding notes produces nonsense. if matches!(task_mode, kres_agents::TaskMode::Coding) { @@ -1992,8 +1953,7 @@ impl Session { // multi-angle spread would be overkill for // this prompt. let res = match task_mode { - kres_agents::TaskMode::Coding - | kres_agents::TaskMode::Generic => { + kres_agents::TaskMode::Coding | kres_agents::TaskMode::Generic => { orc_task .run_once_with_ctx(&text, &ctx, &handle.shutdown) .await @@ -2033,11 +1993,7 @@ impl Session { // cannot silently clobber // identifying fields. let new_plan = rewrite.apply_to(prior.as_ref()); - log_plan_change( - "slow: plan rewrite", - prior.as_ref(), - &new_plan, - ); + log_plan_change("slow: plan rewrite", prior.as_ref(), &new_plan); mgr.set_plan(Some(new_plan)).await; } } @@ -2531,16 +2487,18 @@ impl Session { // links either way. let mut linked: Vec<&kres_core::TodoItem> = Vec::new(); for tid in &s.todo_ids { - if let Some(t) = todo.iter().find(|i| { - (!i.id.is_empty() && i.id == *tid) || i.name == *tid - }) { + if let Some(t) = todo + .iter() + .find(|i| (!i.id.is_empty() && i.id == *tid) || i.name == *tid) + { if !linked.iter().any(|lt| std::ptr::eq(*lt, t)) { linked.push(t); } } } for t in &todo { - if !t.step_id.is_empty() && t.step_id == s.id + if !t.step_id.is_empty() + && t.step_id == s.id && !linked.iter().any(|lt| std::ptr::eq(*lt, t)) { linked.push(t); @@ -2591,16 +2549,12 @@ impl Session { } } - /// `/summary` — synthesise every `(task, analysis)` entry in the - /// accumulated ledger into a single markdown narrative. - /// - /// The calls the main agent to generate a smart - /// synthesis; kres currently produces a deterministic concatenation - /// (TODO: add an LLM synthesiser once the summariser agent config - /// is defined). Matches the shape of `/summary`; pass - /// `markdown=true` (via the `/summary-markdown` slash command) to - /// select the markdown-variant template and default the output - /// filename to `bug-report.md` instead of `bug-report.txt`. + /// `/summary` — render the run's report.md + findings.json into + /// a plain-text summary via the fast agent using the `summary` + /// slash-command template. Pass `markdown=true` (via + /// `/summary-markdown`) to select the markdown-variant template + /// and default the output filename to `summary.md` instead of + /// `summary.txt`. async fn cmd_summary(&self, filename: Option, markdown: bool) { let Some(orc) = self.orchestrator.as_ref() else { async_println( @@ -2621,7 +2575,7 @@ impl Session { } // Output goes to the explicit --results dir when the operator // set one (so prompt.md, findings.json, report.md, and - // bug-report.txt all live together). Without --results, fall + // summary.txt all live together). Without --results, fall // back to the report.md's parent — that's still inside the // defaulted ~/.kres/sessions// tree, just not flagged as // operator-chosen. @@ -2630,13 +2584,12 @@ impl Session { .results_dir .clone() .or_else(|| report_path.parent().map(std::path::Path::to_path_buf)); - // /summary-markdown defaults the filename to bug-report.md - // instead of bug-report.txt so the operator's explicit - // filename wins (--summary --markdown behaves the same at - // the CLI). + // /summary-markdown defaults the filename to summary.md + // instead of summary.txt; --summary-markdown at the CLI + // behaves the same way. let default_name: Option<&str> = match filename.as_deref() { Some(_) => None, - None if markdown => Some("bug-report.md"), + None if markdown => Some("summary.md"), None => None, }; let effective_name = filename.as_deref().or(default_name); @@ -2678,9 +2631,13 @@ impl Session { max_tokens: orc.fast_max_tokens, max_input_tokens: orc.fast_max_input_tokens, }; - let label = if markdown { "/summary-markdown" } else { "/summary" }; + let label = if markdown { + "/summary-markdown" + } else { + "/summary" + }; async_println(format!( - "{label}: rendering bug report to {}", + "{label}: rendering summary to {}", output_path.display() )); if let Err(e) = crate::summary::run_summary(inputs).await { @@ -2697,14 +2654,10 @@ impl Session { async fn cmd_review(&self, target: String) { let target = target.trim(); if target.is_empty() { - async_println( - "/review: expected a target, e.g. /review fs/btrfs/ctree.c", - ); + async_println("/review: expected a target, e.g. /review fs/btrfs/ctree.c"); return; } - let Some((src, body)) = - kres_agents::user_commands::compose("review", target) - else { + let Some((src, body)) = kres_agents::user_commands::compose("review", target) else { async_println( "/review: `review` template missing from the embedded table — this is a build bug", ); @@ -2833,10 +2786,7 @@ impl Session { // /stop parks the latch. The operator has to re-consent // (via /continue or a new prompt) before auto-continue // resumes. - if self - .stop_latched - .load(std::sync::atomic::Ordering::Acquire) - { + if self.stop_latched.load(std::sync::atomic::Ordering::Acquire) { return false; } let running = self.mgr.active_count().await; @@ -2950,9 +2900,7 @@ impl Session { // grants from the prior topic in place and a follow-up // prompt on a different topic could quietly read paths the // operator forgot they'd allowed. - let dropped_grants = kres_core::consent::get() - .map(|s| s.clear()) - .unwrap_or(0); + let dropped_grants = kres_core::consent::get().map(|s| s.clear()).unwrap_or(0); println!( "/clear: stopped {} task(s), reset findings + todo + accumulated context, dropped {} consent grant(s)", out.stopped + out.grace_expired, @@ -2968,7 +2916,10 @@ impl Session { async fn cmd_compact(&self) { let entries = self.accumulated.lock().await.clone(); if entries.len() <= 1 { - println!("/compact: nothing to compact (ledger has {} entry)", entries.len()); + println!( + "/compact: nothing to compact (ledger has {} entry)", + entries.len() + ); return; } let Some(orc) = self.orchestrator.as_ref() else { @@ -3112,7 +3063,7 @@ fn build_recent_context_preamble(entries: &[AccumulatedEntry], cap: usize) -> St // Budget each entry: at most half the remaining cap, so an // early giant entry can't starve the rest. Cap at 2k chars // per entry regardless. - let entry_budget = (remaining / 2).max(400).min(2_000); + let entry_budget = (remaining / 2).clamp(400, 2_000); let head: String = e.analysis.chars().take(entry_budget).collect(); out.push_str(&format!("### {}\n{}", e.task, head)); if e.analysis.chars().count() > entry_budget { @@ -3149,10 +3100,7 @@ fn build_recent_context_preamble(entries: &[AccumulatedEntry], cap: usize) -> St /// operator deliberately drops a file under the new path. fn load_prompt_disk_then_embedded(basename: &str) -> Option { if let Some(home) = dirs::home_dir() { - let p = home - .join(".kres") - .join("system-prompts") - .join(basename); + let p = home.join(".kres").join("system-prompts").join(basename); if let Ok(s) = std::fs::read_to_string(&p) { if !s.trim().is_empty() { return Some(s); @@ -3348,10 +3296,7 @@ async fn apply_code_edits( Err(err) => { failed += 1; let text = err.to_string(); - kres_core::async_eprintln!( - "[coding-edit] {}: {text}", - e.file_path - ); + kres_core::async_eprintln!("[coding-edit] {}: {text}", e.file_path); results.push(AppliedEdit { file_path: e.file_path.clone(), result: Err(text), @@ -3396,7 +3341,7 @@ pub(crate) fn format_applied_edits_trailer(edits: &[AppliedEdit]) -> String { // msg starts with "[edit ] N replacement(s) (..." // — drop the `[edit ] ` prefix to keep the trailer // tight; the path is already on the line. - let tail = msg.splitn(2, "] ").nth(1).unwrap_or(msg); + let tail = msg.split_once("] ").map(|x| x.1).unwrap_or(msg); s.push_str(": "); // Only keep the first line of the preview block — the // full 5-line context lives in the stderr log. @@ -3416,17 +3361,10 @@ pub(crate) fn format_applied_edits_trailer(edits: &[AppliedEdit]) -> String { s } -async fn persist_code_output( - workspace: &Path, - task_name: &str, - files: &[kres_core::CodeFile], -) { +async fn persist_code_output(workspace: &Path, task_name: &str, files: &[kres_core::CodeFile]) { let base = workspace.to_path_buf(); if let Err(e) = tokio::fs::create_dir_all(&base).await { - kres_core::async_eprintln!( - "[coding] create {} failed: {e}", - base.display() - ); + kres_core::async_eprintln!("[coding] create {} failed: {e}", base.display()); return; } let mut wrote = 0usize; @@ -3446,10 +3384,7 @@ async fn persist_code_output( let out = base.join(rel); if let Some(parent) = out.parent() { if let Err(e) = tokio::fs::create_dir_all(parent).await { - kres_core::async_eprintln!( - "[coding] mkdir {} failed: {e}", - parent.display() - ); + kres_core::async_eprintln!("[coding] mkdir {} failed: {e}", parent.display()); continue; } } @@ -3457,15 +3392,10 @@ async fn persist_code_output( // the new content, never a truncated partial. let tmp = out.with_extension(format!( "{}.tmp", - out.extension() - .and_then(|e| e.to_str()) - .unwrap_or("") + out.extension().and_then(|e| e.to_str()).unwrap_or("") )); if let Err(e) = tokio::fs::write(&tmp, f.content.as_bytes()).await { - kres_core::async_eprintln!( - "[coding] write {} failed: {e}", - tmp.display() - ); + kres_core::async_eprintln!("[coding] write {} failed: {e}", tmp.display()); continue; } if let Err(e) = tokio::fs::rename(&tmp, &out).await { @@ -3529,10 +3459,7 @@ fn report_reaped(r: &kres_core::ReapedTask) { } } -fn read_stdin( - tx: mpsc::UnboundedSender, - mut ack_rx: mpsc::UnboundedReceiver<()>, -) { +fn read_stdin(tx: mpsc::UnboundedSender, mut ack_rx: mpsc::UnboundedReceiver<()>) { // rustyline: line-editing + ^R history search + arrow-key recall. // History persists to $HOME/.kres/history. Falls back to plain // stdin on any rustyline init failure so a weird terminal doesn't @@ -3694,9 +3621,11 @@ fn print_help() { println!(" /load submit a file's contents as the next prompt"); println!(" /edit open $EDITOR on a scratch file, submit on save"); println!(" /followup list items deferred by goal/--turns"); - println!(" /review compose the embedded `review` template with and submit"); - println!(" /summary [FILE] render report.md+findings.json into a plain-text bug report (default bug-report.txt)"); - println!(" /summary-markdown [FILE] render the markdown variant (default bug-report.md)"); + println!( + " /review compose the embedded `review` template with and submit" + ); + println!(" /summary [FILE] render report.md+findings.json into a plain-text summary (default summary.txt)"); + println!(" /summary-markdown [FILE] render the markdown variant (default summary.md)"); println!(" /extract ... copy artifacts (--dir, --report, --todo, --findings)"); println!(" /done N remove the N'th pending todo"); println!(" /todo --clear drop every todo item"); @@ -3706,9 +3635,7 @@ fn print_help() { println!(" /quit, /exit leave the REPL"); println!(" submit as a prompt"); println!(); - println!( - "override slash-command templates by dropping a file at ~/.kres/commands/.md" - ); + println!("override slash-command templates by dropping a file at ~/.kres/commands/.md"); } fn truncate(s: &str, n: usize) -> String { @@ -3796,8 +3723,11 @@ pub(crate) fn log_plan_status_transitions( let (Some(prior), Some(after)) = (prior, after) else { return; }; - let prior_by_id: std::collections::BTreeMap<&str, kres_core::PlanStepStatus> = - prior.steps.iter().map(|s| (s.id.as_str(), s.status)).collect(); + let prior_by_id: std::collections::BTreeMap<&str, kres_core::PlanStepStatus> = prior + .steps + .iter() + .map(|s| (s.id.as_str(), s.status)) + .collect(); for s in &after.steps { if let Some(prior_status) = prior_by_id.get(s.id.as_str()) { if *prior_status != s.status { diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index cdd71a7..da218d9 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -1,4 +1,4 @@ -//! /summary and `kres --summary` — render a plain-text bug report from +//! /summary and `kres --summary` — render a plain-text summary from //! a research run's report.md + findings.json. //! //! The summariser is backed by the `/summary` (or @@ -21,29 +21,31 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; +use kres_core::findings::Finding; use serde_json::json; use kres_agents::AgentConfig; use kres_core::findings::FindingsFile; use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; +/// Conservative fallback when the caller didn't set max_input_tokens +/// and we need a budget to decide staging. Claude's default 200K +/// context, minus headroom for the output and protocol overhead. +const DEFAULT_INPUT_BUDGET: u32 = 180_000; + /// Default on-disk override location for the plain-text template. /// Empty by default; an operator who wants to shadow the embedded /// prompt drops a file at `~/.kres/commands/summary.md`. Returns /// None when $HOME is unset. pub fn default_template_path() -> Option { - dirs::home_dir() - .map(|h| h.join(".kres").join("commands").join("summary.md")) + dirs::home_dir().map(|h| h.join(".kres").join("commands").join("summary.md")) } /// Default on-disk override location for the markdown variant. -/// `--markdown` selects this instead of the plain-text one. +/// `/summary-markdown` (and `--summary-markdown` on the CLI) selects +/// this instead of the plain-text one. pub fn default_markdown_template_path() -> Option { - dirs::home_dir().map(|h| { - h.join(".kres") - .join("commands") - .join("summary-markdown.md") - }) + dirs::home_dir().map(|h| h.join(".kres").join("commands").join("summary-markdown.md")) } /// All the inputs to one summary run. Constructed once by either the @@ -73,12 +75,13 @@ pub struct SummaryInputs { pub max_input_tokens: Option, } -/// Build the default output path for a bug report given an optional +/// Build the default output path for a summary given an optional /// `--results` directory and an optional caller-supplied filename. -/// Filename defaults to `bug-report.txt`; when results_dir is None the +/// Filename defaults to `summary.txt`; callers wanting the markdown +/// variant pass `Some("summary.md")`. When results_dir is None the /// file lands in the current working directory. pub fn default_output_path(results_dir: Option<&Path>, filename: Option<&str>) -> PathBuf { - let name = filename.unwrap_or("bug-report.txt"); + let name = filename.unwrap_or("summary.txt"); match results_dir { Some(d) => d.join(name), None => PathBuf::from(name), @@ -127,31 +130,31 @@ fn resolve_template(inputs: &SummaryInputs) -> Result<(String, String)> { "summary-markdown", ) } else { - ( - default_template_path(), - "", - "summary", - ) + (default_template_path(), "", "summary") }; if let Some(p) = disk_default.filter(|p| p.exists()) { let text = std::fs::read_to_string(&p) .with_context(|| format!("reading template {}", p.display()))?; return Ok((p.display().to_string(), text)); } - let body = kres_agents::user_commands::lookup(fallback_name) - .ok_or_else(|| { - anyhow!( - "embedded `{fallback_name}` template missing from user_commands — build bug" - ) - })?; + let body = kres_agents::user_commands::lookup(fallback_name).ok_or_else(|| { + anyhow!("embedded `{fallback_name}` template missing from user_commands — build bug") + })?; Ok((fallback_label.to_string(), body)) } /// Run the summary pipeline. Reads report.md (required) and /// findings.json (optional — missing is a warning, not an error), /// sends them to the fast agent with the embedded template as the -/// system prompt, and writes the plain-text response to -/// `inputs.output_path`. +/// system prompt, and writes the response to `inputs.output_path`. +/// +/// When the assembled prompt exceeds `max_input_tokens` (or the +/// conservative [`DEFAULT_INPUT_BUDGET`] fallback), the run switches +/// to a map-reduce shape: findings are split into chunks that each +/// fit, the template is applied to each chunk to produce a partial +/// summary, and a final combine call merges the partials into one +/// output. The single-call path stays the default when the payload +/// fits. pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { let report_md = std::fs::read_to_string(&inputs.report_path) .with_context(|| format!("reading report {}", inputs.report_path.display()))?; @@ -186,69 +189,75 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { } }; - let findings_missing = findings.is_empty(); - let note = if findings_missing { - "findings.json absent or empty; derive bugs from report.md alone. Do not invent structured facts." - } else if !findings_note.is_empty() { - findings_note.as_str() - } else { - "" - }; - let prompt_json = serde_json::to_string(&json!({ - "task": "bug_report", - "original_prompt": inputs.original_prompt.as_deref().unwrap_or(""), - "report_md": report_md, - "findings": findings, - "findings_missing": findings_missing, - "note": note, - }))?; - // Resolve the system prompt template: explicit --template wins, // else the on-disk operator override under ~/.kres/commands/, - // else the compiled-in default. `--markdown` picks the markdown - // variant at each hop. We read each file at most once — the - // per-hop log line names the source so operators can tell which - // template actually shaped the report. + // else the compiled-in default. `inputs.markdown` (from the + // `/summary-markdown` command or the `--summary-markdown` CLI + // flag) picks the markdown variant at each hop. We read each + // file at most once — the per-hop log line names the source so + // operators can tell which template actually shaped the output. let (template_src, template_text) = resolve_template(&inputs)?; eprintln!("summary: template = {}", template_src); let mut cfg = CallConfig::defaults_for(inputs.model.clone()) .with_max_tokens(inputs.max_tokens) .with_stream_label("summary"); - cfg = cfg.with_system(template_text); + cfg = cfg.with_system(template_text.clone()); if let Some(n) = inputs.max_input_tokens { cfg = cfg.with_max_input_tokens(n); } - let messages = vec![Message { - role: "user".into(), - content: prompt_json, - cache: false, - cached_prefix: None, - }]; + let budget = inputs.max_input_tokens.unwrap_or(DEFAULT_INPUT_BUDGET); + let original_prompt = inputs.original_prompt.as_deref().unwrap_or(""); + let findings_note_opt = if findings_note.is_empty() { + None + } else { + Some(findings_note.as_str()) + }; + // One-shot attempt first: build the full prompt and see if it + // fits the budget. `count_tokens_exact` returns None on API + // failure — fall back to a chars/4 heuristic rather than + // assuming either direction. + let full_prompt = build_prompt_json(original_prompt, &report_md, &findings, findings_note_opt)?; + let full_messages = vec![user_message(&full_prompt)]; + let size = size_call(&inputs.client, &cfg, &full_messages, budget).await; eprintln!( - "summary: sending to {} ({} finding(s), {} chars of report, original_prompt={})", - inputs.model.id, + "summary: input sizing findings={} report_chars={} tokens={:?} budget={}", findings.len(), report_md.len(), - if inputs.original_prompt.is_some() { - "yes" - } else { - "no" - } + size, + budget ); - let resp = inputs - .client - .messages_streaming(&cfg, &messages) - .await - .map_err(|e| anyhow!("summary call failed: {e}"))?; - let text = extract_text(&resp); + + let needs_staging = size.map(|t| t > budget as u64).unwrap_or(false); + let text = if !needs_staging { + eprintln!( + "summary: single-shot to {} ({} finding(s), original_prompt={})", + inputs.model.id, + findings.len(), + if original_prompt.is_empty() { + "no" + } else { + "yes" + }, + ); + call_and_extract(&inputs.client, &cfg, &full_messages, "summary").await? + } else { + stage_summary( + &inputs, + &cfg, + original_prompt, + &report_md, + &findings, + findings_note_opt, + budget, + ) + .await? + }; + if text.trim().is_empty() { - return Err(anyhow!( - "summary call returned empty body (stop_reason={:?})", - resp.stop_reason - )); + return Err(anyhow!("summary produced empty body")); } if let Some(parent) = inputs.output_path.parent() { @@ -260,15 +269,351 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { std::fs::write(&inputs.output_path, &text) .with_context(|| format!("writing summary to {}", inputs.output_path.display()))?; eprintln!( - "summary: wrote {} chars to {} (usage in={} out={})", + "summary: wrote {} chars to {}", text.len(), inputs.output_path.display(), - resp.usage.input_tokens, - resp.usage.output_tokens ); Ok(()) } +/// Map-reduce path: chunk findings into groups that each fit the +/// input budget (with the same report.md + template attached), call +/// the fast agent on each, then combine the partials into one final +/// output. Triggered by `run_summary` when the single-shot prompt +/// oversizes. +#[allow(clippy::too_many_arguments)] +async fn stage_summary( + inputs: &SummaryInputs, + cfg: &CallConfig, + original_prompt: &str, + report_md: &str, + findings: &[Finding], + findings_note_opt: Option<&str>, + budget: u32, +) -> Result { + if findings.is_empty() { + return Err(anyhow!( + "summary prompt exceeds {} input tokens but there are no findings to chunk \ + (report.md alone overflows budget). Trim the report or raise max_input_tokens.", + budget + )); + } + let chunks = chunk_findings_to_fit( + &inputs.client, + cfg, + original_prompt, + report_md, + findings, + budget, + ) + .await?; + eprintln!( + "summary: staging: {} chunk(s) over {} finding(s); will render partials then combine", + chunks.len(), + findings.len(), + ); + + let mut partials = Vec::with_capacity(chunks.len()); + for (idx, chunk) in chunks.iter().enumerate() { + let note = partial_note(idx + 1, chunks.len(), findings_note_opt); + let prompt_json = + build_partial_prompt_json(original_prompt, report_md, chunk, Some(note.as_str()))?; + let messages = vec![user_message(&prompt_json)]; + let label = format!("summary partial {}/{}", idx + 1, chunks.len()); + eprintln!( + "summary: partial {}/{} — {} finding(s)", + idx + 1, + chunks.len(), + chunk.len(), + ); + let text = call_and_extract(&inputs.client, cfg, &messages, &label).await?; + partials.push(text); + } + + // Combine pass: synthesise a dedicated system prompt that tells + // the fast agent to merge the partials without re-deriving + // structured facts. Falls back to the same model/budget config as + // the partials. + let combine_system = combine_system_prompt(inputs.markdown); + let combine_cfg = CallConfig::defaults_for(inputs.model.clone()) + .with_max_tokens(inputs.max_tokens) + .with_stream_label("summary combine") + .with_system(combine_system); + let combine_cfg = match inputs.max_input_tokens { + Some(n) => combine_cfg.with_max_input_tokens(n), + None => combine_cfg, + }; + let combine_json = serde_json::to_string(&json!({ + "task": "combine_summaries", + "original_prompt": original_prompt, + "partials": partials, + }))?; + let combine_messages = vec![user_message(&combine_json)]; + // Pre-size the combine call. Partials are typically smaller than + // the source they cover, but a lens that expands prose can leave + // the concatenation over budget. Surface that as a clear error + // rather than letting the LLM call fail mid-stream, so the + // operator knows to raise max_input_tokens (or trim report.md). + let combine_size = size_call(&inputs.client, &combine_cfg, &combine_messages, budget).await; + eprintln!( + "summary: combine sizing partials={} tokens={:?} budget={}", + partials.len(), + combine_size, + budget, + ); + if let Some(n) = combine_size { + if n > budget as u64 { + return Err(anyhow!( + "combined partials ({n} tokens) exceed the {budget}-token input budget — \ + raise max_input_tokens or shrink report.md" + )); + } + } + eprintln!( + "summary: combining {} partial(s) into final output", + partials.len() + ); + call_and_extract( + &inputs.client, + &combine_cfg, + &combine_messages, + "summary combine", + ) + .await +} + +/// Split findings into consecutive chunks such that each (chunk + +/// report.md + template) fits `budget` input tokens. Starts at 2 +/// parts (the caller only invokes this after the full 1-chunk +/// payload already oversized) and doubles until every partition +/// fits or each chunk is a single finding. Returns the chunks as +/// borrowed slices. +async fn chunk_findings_to_fit<'a>( + client: &Client, + cfg: &CallConfig, + original_prompt: &str, + report_md: &str, + findings: &'a [Finding], + budget: u32, +) -> Result> { + if findings.len() < 2 { + return Err(anyhow!( + "cannot chunk {} finding(s) to fit the {} input-token budget; \ + report.md alone is the overflow source", + findings.len(), + budget + )); + } + // Size each chunk with a representative partial_note applied so + // the probe matches the real partial call within a few bytes. + // Picking idx/total from the current `parts` keeps the format! + // string aligned with what the partial call will emit. + let mut parts: usize = 2; + loop { + let chunks = split_evenly(findings, parts); + let mut all_fit = true; + for (idx, chunk) in chunks.iter().enumerate() { + let probe_note = partial_note(idx + 1, chunks.len(), None); + let prompt = build_partial_prompt_json( + original_prompt, + report_md, + chunk, + Some(probe_note.as_str()), + )?; + let messages = vec![user_message(&prompt)]; + let size = size_call(client, cfg, &messages, budget).await; + let over = size.map(|t| t > budget as u64).unwrap_or(false); + if over { + all_fit = false; + break; + } + } + if all_fit { + return Ok(chunks); + } + if parts >= findings.len() { + return Err(anyhow!( + "even one finding per chunk exceeds the {} input-token budget; \ + report.md is likely the overflow source", + budget + )); + } + parts = (parts * 2).min(findings.len()); + } +} + +/// Split `items` into `parts` contiguous slices, biggest-first when +/// the length doesn't divide evenly (so earlier chunks absorb the +/// remainder). +fn split_evenly(items: &[T], parts: usize) -> Vec<&[T]> { + if parts == 0 || items.is_empty() { + return vec![items]; + } + let base = items.len() / parts; + let rem = items.len() % parts; + let mut out = Vec::with_capacity(parts); + let mut start = 0; + for i in 0..parts { + let len = base + if i < rem { 1 } else { 0 }; + if len == 0 { + continue; + } + out.push(&items[start..start + len]); + start += len; + } + out +} + +fn build_prompt_json( + original_prompt: &str, + report_md: &str, + findings: &[Finding], + findings_note: Option<&str>, +) -> Result { + let findings_missing = findings.is_empty(); + let note = if findings_missing { + "findings.json absent or empty; derive the summary from report.md alone. Do not invent structured facts." + } else { + findings_note.unwrap_or("") + }; + Ok(serde_json::to_string(&json!({ + "task": "summary", + "original_prompt": original_prompt, + "report_md": report_md, + "findings": findings, + "findings_missing": findings_missing, + "note": note, + }))?) +} + +fn build_partial_prompt_json( + original_prompt: &str, + report_md: &str, + findings: &[Finding], + extra_note: Option<&str>, +) -> Result { + let note = extra_note.unwrap_or(""); + Ok(serde_json::to_string(&json!({ + "task": "summary", + "original_prompt": original_prompt, + "report_md": report_md, + "findings": findings, + "findings_missing": false, + "note": note, + }))?) +} + +fn partial_note(idx: usize, total: usize, carry_over: Option<&str>) -> String { + let mut n = format!( + "You are rendering partial summary {idx} of {total} for the same research run. \ + Cover only the findings provided in this chunk. A later stage will merge the \ + partials into a single final summary, so emit the sections in the template's \ + normal shape and skip any closing or global framing that would duplicate \ + across partials." + ); + if let Some(extra) = carry_over { + if !extra.is_empty() { + n.push(' '); + n.push_str(extra); + } + } + n +} + +fn combine_system_prompt(markdown: bool) -> String { + let flavour = if markdown { "markdown" } else { "plain text" }; + format!( + "You are merging partial summaries produced from the same research run into a \ + single {flavour} summary. Every section in the partials must appear in the \ + final output — merge duplicates (the same underlying topic or finding) rather \ + than listing them twice. Preserve the style, tone, structure, and line \ + wrapping the partials already use; do not invent new section headings or \ + framing. If the partials open with a shared contextual lead-in, keep one copy \ + at the top. End the output with a blank line." + ) +} + +fn user_message(content: &str) -> Message { + Message { + role: "user".into(), + content: content.to_string(), + cache: false, + cached_prefix: None, + } +} + +/// Safety factor on the chars/4 heuristic. When the cheap estimate +/// comes in at <= budget * SAFE_FRAC, we trust it and skip the +/// count_tokens_exact round-trip; the trip costs one API hit per +/// summary attempt and is pure overhead for payloads well below +/// budget. 0.75 leaves slack for the chars/4 estimate's own +/// inaccuracy (it undercounts long identifiers and multi-byte +/// code points). +const SAFE_FRAC: f64 = 0.75; + +async fn count_or_estimate(client: &Client, cfg: &CallConfig, messages: &[Message]) -> Option { + if let Some(n) = client.count_tokens_exact(cfg, messages).await { + return Some(n); + } + Some(cheap_estimate(cfg, messages)) +} + +/// chars/4 estimate over user content + system prompt. Mirrors the +/// rate-limit path's fallback heuristic. Used both as a gate before +/// the exact count call and as the last-resort answer when the exact +/// endpoint itself fails. +fn cheap_estimate(cfg: &CallConfig, messages: &[Message]) -> u64 { + let user_chars: usize = messages.iter().map(|m| m.content.len()).sum(); + let system_chars = cfg.system.as_ref().map(|s| s.len()).unwrap_or(0); + ((user_chars + system_chars) as u64) / 4 +} + +/// Sizing gate used before every LLM call in the summary pipeline. +/// Skip the count_tokens_exact round-trip when the chars/4 estimate +/// is comfortably under budget — a ~2× cost saving on small runs. +/// When the estimate is close to (or over) budget, fall through to +/// the exact count so the staging decision reflects the real token +/// count rather than a lossy heuristic. +async fn size_call( + client: &Client, + cfg: &CallConfig, + messages: &[Message], + budget: u32, +) -> Option { + let est = cheap_estimate(cfg, messages); + let safe_ceiling = (budget as f64 * SAFE_FRAC) as u64; + if est <= safe_ceiling { + return Some(est); + } + count_or_estimate(client, cfg, messages).await +} + +async fn call_and_extract( + client: &Client, + cfg: &CallConfig, + messages: &[Message], + stage: &str, +) -> Result { + let resp = client + .messages_streaming(cfg, messages) + .await + .map_err(|e| anyhow!("{stage}: call failed: {e}"))?; + let text = extract_text(&resp); + if text.trim().is_empty() { + return Err(anyhow!( + "{stage}: empty body (stop_reason={:?})", + resp.stop_reason + )); + } + eprintln!( + "{stage}: {} chars (usage in={} out={})", + text.len(), + resp.usage.input_tokens, + resp.usage.output_tokens, + ); + Ok(text) +} + fn extract_text(resp: &kres_llm::request::MessagesResponse) -> String { let mut out = String::new(); for block in &resp.content { diff --git a/kres/src/main.rs b/kres/src/main.rs index 8fca766..24faa7f 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -168,35 +168,38 @@ struct ReplArgs { #[arg(long, default_value_t = false)] stdio: bool, - /// Render a bug report from a prior run's report.md + + /// Render a summary from a prior run's report.md + /// findings.json and exit without starting the REPL. Uses the - /// fast agent for the one-shot call and the bug-summary template as - /// the system prompt. Pairs with --report, --findings, and + /// fast agent with the embedded `summary` template as the + /// system prompt. Single-shot when the inputs fit + /// `max_input_tokens`; on overflow, splits findings into chunks, + /// renders one partial summary per chunk, then runs a combine + /// pass to merge them. Pairs with --report, --findings, and /// --results (or their defaults) to locate the inputs. The - /// output filename is always bug-report.txt, placed in the - /// results directory when --results was supplied, otherwise in - /// the current working directory. + /// output filename is `summary.txt`, placed in the results + /// directory when --results was supplied, otherwise in the + /// current working directory. #[arg(long, default_value_t = false)] summary: bool, - /// Override the bug-summary template path for --summary. Accepted - /// by `/summary` too. When omitted, kres reads - /// ~/.kres/commands/summary.md (the operator-override path — - /// empty by default) and falls back to the compiled-in copy - /// bundled in the binary (see - /// `kres-agents/src/user_commands.rs`). `--markdown` selects - /// `summary-markdown.md` at each hop instead. + /// Markdown variant of --summary. Selects the + /// `summary-markdown` template and writes `summary.md` instead + /// of `summary.txt`. Mutually useful with --template FILE, in + /// which case the explicit template wins over the variant + /// picker but the filename still defaults to `summary.md`. + #[arg(long, default_value_t = false)] + summary_markdown: bool, + + /// Override the summary template path for --summary / + /// --summary-markdown. Accepted by `/summary` too. When + /// omitted, kres reads `~/.kres/commands/summary.md` (or + /// `summary-markdown.md` for the markdown variant — the + /// operator-override path, empty by default) and falls back to + /// the compiled-in copy bundled in the binary (see + /// `kres-agents/src/user_commands.rs`). #[arg(long, value_name = "FILE")] template: Option, - /// Render the bug report as markdown instead of plain text. - /// Selects the `bug-summary-markdown.md` template variant and - /// writes `bug-report.md` (instead of `bug-report.txt`). Pairs - /// with `--summary`. Ignored when `--template FILE` is passed — - /// an explicit template wins over the variant picker. - #[arg(long, default_value_t = false)] - markdown: bool, - /// Allow one additional non-MCP action type for this session. /// Repeatable (`--allow bash --allow git`) or comma-separated /// (`--allow bash,git`). Adds to whatever `actions.allowed` @@ -344,9 +347,7 @@ fn resolve_prompt_arg(raw: &str) -> Result<(String, String)> { // (disk-first + embedded fallback + name-validation). The // validation inside compose covers the same character set // we'd enforce here, so there's no need to pre-filter. - if let Some((src, composed)) = - kres_agents::user_commands::compose(head, rest) - { + if let Some((src, composed)) = kres_agents::user_commands::compose(head, rest) { return Ok((src, composed)); } // Legacy: ~/.kres/prompts/-template.md. Kept for @@ -359,9 +360,7 @@ fn resolve_prompt_arg(raw: &str) -> Result<(String, String)> { .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); if is_word { if let Some(dir) = kres_dir() { - let tmpl = dir - .join("prompts") - .join(format!("{}-template.md", head)); + let tmpl = dir.join("prompts").join(format!("{}-template.md", head)); if tmpl.exists() { let body = std::fs::read_to_string(&tmpl) .with_context(|| format!("reading template {}", tmpl.display()))?; @@ -473,8 +472,7 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // When --slow is passed as a known tag (sonnet/opus) we also map // it to a model id, so `--slow sonnet` actually switches the // slow model. Explicit --slow-model still beats the tag mapping. - let mut settings = - kres_repl::Settings::load_merged(&args.workspace); + let mut settings = kres_repl::Settings::load_merged(&args.workspace); // Only map the --slow tag to a model id when the operator // actually passed --slow. Without this gate the clap default // "sonnet" would unconditionally overwrite settings.models.slow @@ -496,9 +494,15 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // Individual `--findings FILE`, `--report FILE`, `--todo FILE` // override their own slot. When --results is absent, the default // is ~/.kres/sessions// (session-id is a timestamp). + // Treat --summary and --summary-markdown as the same "standalone + // summary" entry; the markdown flag just picks the variant + // template and filename further down. + let summary_mode = args.summary || args.summary_markdown; + let markdown = args.summary_markdown; + // In --summary mode we avoid creating a fresh session directory // because the operator points at an existing run's artifacts. - let results_dir = match (args.results.clone(), args.summary) { + let results_dir = match (args.results.clone(), summary_mode) { (Some(d), _) => d, (None, true) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), (None, false) => { @@ -521,13 +525,13 @@ async fn run_repl(args: ReplArgs) -> Result<()> { .clone() .unwrap_or_else(|| results_dir.join("todo.md")); - // --- --summary: standalone bug-report rendering --------------- + // --- --summary / --summary-markdown: standalone rendering ---- // Inputs come from --report / --findings / --results (or their - // defaults above). Output is always bug-report.txt, living in - // the results dir when --results was set and the cwd otherwise. - // Exits right after the file is written; no REPL, no MCP, no - // orchestrator, no turn logger. - if args.summary { + // defaults above). Output is `summary.txt` (or `summary.md` with + // --summary-markdown), living in the results dir when --results + // was set and the cwd otherwise. Exits right after the file is + // written; no REPL, no MCP, no orchestrator, no turn logger. + if summary_mode { let fast_cfg_path = match fast_agent.as_ref() { Some(p) => p.clone(), None => { @@ -557,17 +561,11 @@ async fn run_repl(args: ReplArgs) -> Result<()> { kres_repl::summary::load_fast_for_summary(&fast_cfg_path, &settings)?; // `results_dir` is already cwd when --results was absent (see // the match at the top of run_repl), so the output lands - // alongside the inputs either way. `--markdown` flips the - // default filename to bug-report.md. - let default_filename = if args.markdown { - Some("bug-report.md") - } else { - None - }; - let output_path = kres_repl::summary::default_output_path( - Some(results_dir.as_path()), - default_filename, - ); + // alongside the inputs either way. `--summary-markdown` flips + // the default filename to summary.md. + let default_filename = if markdown { Some("summary.md") } else { None }; + let output_path = + kres_repl::summary::default_output_path(Some(results_dir.as_path()), default_filename); // Original prompt lookup: prompt.md in the results dir wins, // since we only ever write it there (and only when the user // passed --results). Nothing to read from memory in the @@ -597,7 +595,7 @@ async fn run_repl(args: ReplArgs) -> Result<()> { findings_path: findings_opt, output_path, template_path: args.template.clone(), - markdown: args.markdown, + markdown, original_prompt, client: fast_client, model: fast_model, @@ -1209,14 +1207,7 @@ mod tests { // into ["bash", "git"]. Repeatable-plus-delimited is what // clap's conventional pattern expects, and this pins it so // a future refactor can't silently drop the delimiter. - let c = Cli::try_parse_from([ - "kres", - "--allow", - "bash,git", - "--allow", - "edit", - ]) - .unwrap(); + let c = Cli::try_parse_from(["kres", "--allow", "bash,git", "--allow", "edit"]).unwrap(); assert_eq!(c.repl.allow, vec!["bash", "git", "edit"]); } @@ -1230,8 +1221,8 @@ mod tests { fn resolve_prompt_arg_word_colon_form_hits_user_commands() { // --prompt "review: target" resolves via user_commands to the // embedded review template with the target prepended. - let (src, body) = resolve_prompt_arg("review: fs/btrfs/ctree.c") - .expect("review: form should resolve"); + let (src, body) = + resolve_prompt_arg("review: fs/btrfs/ctree.c").expect("review: form should resolve"); assert!(src.contains("review"), "source label: {src}"); assert!( body.starts_with("fs/btrfs/ctree.c\n\n"), @@ -1244,10 +1235,8 @@ mod tests { fn resolve_prompt_arg_slash_form_equivalent_to_colon_form() { // The whole point of the CLI slash-form: --prompt "/review X" // must produce the same composed prompt as --prompt "review: X". - let (_, colon_body) = - resolve_prompt_arg("review: fs/btrfs/ctree.c").unwrap(); - let (_, slash_body) = - resolve_prompt_arg("/review fs/btrfs/ctree.c").unwrap(); + let (_, colon_body) = resolve_prompt_arg("review: fs/btrfs/ctree.c").unwrap(); + let (_, slash_body) = resolve_prompt_arg("/review fs/btrfs/ctree.c").unwrap(); assert_eq!( colon_body, slash_body, "slash form and colon form must compose identically" @@ -1259,8 +1248,7 @@ mod tests { // A slash prefix with no matching command and no legacy // template on disk must pass through as verbatim prompt // text — NOT error, NOT be silently dropped. - let (src, body) = - resolve_prompt_arg("/no-such-cmd hello world").unwrap(); + let (src, body) = resolve_prompt_arg("/no-such-cmd hello world").unwrap(); assert_eq!(src, ""); assert_eq!(body, "/no-such-cmd hello world"); } @@ -1271,9 +1259,7 @@ mod tests { // doesn't start with a command word must stay inline — this // is the "question like 'when did btrfs: land?' shouldn't // look up a btrfs template" case. - let (src, body) = - resolve_prompt_arg("why does func() return: unusual values?") - .unwrap(); + let (src, body) = resolve_prompt_arg("why does func() return: unusual values?").unwrap(); assert_eq!(src, ""); assert!(body.contains("unusual values")); } From 7dcdeb0dbb1a7b06d8ad4ce8419c1de1e1d6183b Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Wed, 22 Apr 2026 17:12:44 -0700 Subject: [PATCH 36/76] kres-agents: narrow cache prefix to skills only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 0204e154-... on fs/btrfs/inode.c burned 14.5M tokens of cache_creation on code.jsonl for only 2.25M tokens of cache_read (0.16x read/create ratio). Measured cost: caching was 9.5% more expensive than sending everything uncached. Root cause: the cached prefix bundled `question` and `previous_findings`, both of which change per task, so task 2+ never hit the skills cache written by task 1. Narrow CACHED_PREFIX_FIELDS down to just `skills`, the one field that stays byte-stable across tasks within a session. Move `question`, `parallel_lenses`, and `previous_findings` into the volatile tail. plan_rewrite_allowed cannot ride in the prefix either. It is Option with skip_serializing_if=None — present only on the slow agent's first call per top-level prompt, absent otherwise. to_cached_split_json round-trips through serde_json::Value whose Map sorts keys alphabetically, so plan_rewrite_allowed sorts before skills and two prefix shapes end up on the wire: {"skills": ...} (fast calls) {"plan_rewrite_allowed": true, "skills": ...} (slow 1st) Session c5843f10-... confirmed the break: i=2 (fast round 1) and i=4 (slow) had a common prefix of 5 bytes, cache_read=0 on every code.jsonl turn across the first 3 API calls despite an 8.6k skills prefix being written at i=1. Keep plan_rewrite_allowed in the volatile tail; the slow agent still sees it, the cache just stops caring about it. Also drop the tail cache_control on one-shot calls (slow agent, lens fan-out, consolidator, merger, goal define/check, define_plan, todo agent). Each is a single API call per invocation with no round 2 to read the tail cache; today each pays the +25% cache-write tax for nothing. Fast-agent gather rounds keep `cache: true` because round 2+ does read back. Signed-off-by: Chris Mason --- kres-agents/src/consolidate.rs | 4 +- kres-agents/src/goal.rs | 12 ++++-- kres-agents/src/merge.rs | 4 +- kres-agents/src/pipeline.rs | 77 ++++++++++++++++++---------------- kres-agents/src/todo_agent.rs | 4 +- 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/kres-agents/src/consolidate.rs b/kres-agents/src/consolidate.rs index 02f2925..ae47a8c 100644 --- a/kres-agents/src/consolidate.rs +++ b/kres-agents/src/consolidate.rs @@ -116,10 +116,12 @@ pub async fn consolidate_lenses_with_logger( cfg = cfg.with_max_input_tokens(n); } + // Consolidator is one-shot per task; tail cache would never be + // read. Skip the +25% write tax. let messages = vec![Message { role: "user".into(), content: request_text, - cache: true, + cache: false, cached_prefix: None, }]; if let Some(lg) = &logger { diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index be664f0..ed09d2d 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -143,10 +143,12 @@ pub async fn define_goal(gc: &GoalClient, prompt: &str) -> Option` with +/// `skip_serializing_if=None`. Having it in the prefix meant the +/// prefix JSON key set varied (slow-first-call had it, fast calls +/// didn't). `to_cached_split_json` round-trips through +/// `serde_json::Value` whose Map sorts keys alphabetically, so +/// `plan_rewrite_allowed` sorted before `skills` — two prefix +/// shapes on the wire that shared only 5 bytes. Session +/// `c5843f10-…` confirmed: i=2 (fast) and i=4 (slow) had a common +/// prefix of 5 chars, cache_read=0. +/// +/// For fast-agent gather rounds the tail still cache-hits on +/// round 2+ via the `Message::cache` flag; for one-shot +/// slow/lens/consolidate/merge calls the caller drops +/// `Message::cache` entirely so we don't pay the +25% write tax +/// on a tail nothing will read. +const CACHED_PREFIX_FIELDS: &[&str] = &["skills"]; /// Abstraction over the main-agent's data-fetch capability. /// Implementations route followups to MCP tools, grep, read, git. @@ -557,10 +557,14 @@ impl Orchestrator { } let (slow_prefix, slow_suffix) = slow_cp.to_cached_split_json(CACHED_PREFIX_FIELDS)?; let slow_logged = format!("{slow_prefix}{slow_suffix}"); + // Slow agent is one-shot per task — no round 2 will ever + // read the tail cache. Drop `cache` to avoid the +25% write + // tax on the volatile suffix. `cached_prefix` still carries + // a cache_control block so cross-task skills reads hit. let messages = vec![Message { role: "user".into(), content: slow_suffix.clone(), - cache: true, + cache: false, cached_prefix: if slow_prefix.is_empty() { None } else { @@ -936,10 +940,13 @@ impl Orchestrator { let logger = self.logger.clone(); let lens_label = format!("lens {}", lens.name); futures.push(async move { + // Each lens fan-out is a one-shot slow call. Same + // reasoning as the single slow path above: skip the + // tail cache tax, keep the prefix cache for skills. let messages = vec![Message { role: "user".into(), content: lens_suffix, - cache: true, + cache: false, cached_prefix: if lens_prefix.is_empty() { None } else { diff --git a/kres-agents/src/todo_agent.rs b/kres-agents/src/todo_agent.rs index 7a2021e..a70be45 100644 --- a/kres-agents/src/todo_agent.rs +++ b/kres-agents/src/todo_agent.rs @@ -173,10 +173,12 @@ pub async fn update_todo_via_agent_with_logger( if let Some(n) = tc.max_input_tokens { cfg = cfg.with_max_input_tokens(n); } + // Each todo-update call is one-shot (one inference per reap); + // the tail cache would never be read. Skip the +25% write tax. let messages = vec![Message { role: "user".into(), content: request_text.clone(), - cache: true, + cache: false, cached_prefix: None, }]; if let Some(lg) = &logger { From be1ecac1c422b4bebd0fc36648a9556c66290baf Mon Sep 17 00:00:00 2001 From: Breno Leitao Date: Thu, 23 Apr 2026 08:17:06 -0700 Subject: [PATCH 37/76] llm: surface transport failures so offline runs aren't silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection errors (DNS failure, connect refused, timeout, no route) were retried up to 20 times with exponential backoff but never logged, so a kres run on an offline or proxy-broken host looked like a hang. Add an async_eprintln! at every retry and at final exhaustion across all three POST sites (messages, stream_messages, messages_streaming), naming the failure kind and pointing at api.anthropic.com so the operator can spot the connectivity problem immediately. Verified by forcing failures via `https_proxy=http://127.0.0.1:1`, which now produces lines like: [network] messages_streaming attempt=1/21 kind=timeout error=error sending request for url (https://api.anthropic.com/v1/messages) — retrying in 1.04s (check connectivity to api.anthropic.com) After exhaustion the give-up message names the same hostname so the operator knows kres is done retrying rather than still looping. Also drop the now-unused backoff_sleep helper (inlined into the three call sites alongside the new logging). Signed-off-by: Breno Leitao --- kres-llm/src/client.rs | 69 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/kres-llm/src/client.rs b/kres-llm/src/client.rs index 4db7d3c..9e0141d 100644 --- a/kres-llm/src/client.rs +++ b/kres-llm/src/client.rs @@ -146,9 +146,14 @@ impl Client { Ok(r) => r, Err(e) => { if attempt < MAX_RETRIES && is_transport_retryable(&e) { - backoff_sleep(attempt).await; + let wait = backoff_duration(attempt); + log_transport_retry("messages", attempt, MAX_RETRIES, &e, wait); + tokio::time::sleep(wait).await; continue; } + if is_transport_retryable(&e) { + log_transport_giveup("messages", MAX_RETRIES, &e); + } return Err(LlmError::Http(e)); } }; @@ -276,10 +281,15 @@ impl Client { Ok(r) => r, Err(e) => { if attempt < max_retries && is_transport_retryable(&e) { - backoff_sleep(attempt).await; + let wait = backoff_duration(attempt); + log_transport_retry("stream", attempt, max_retries, &e, wait); + tokio::time::sleep(wait).await; last_err = Some(LlmError::Http(e)); continue; } + if is_transport_retryable(&e) { + log_transport_giveup("stream", max_retries, &e); + } return Err(LlmError::Http(e)); } }; @@ -387,9 +397,20 @@ impl Client { Ok(r) => r, Err(e) => { if attempt < MAX_RETRIES && is_transport_retryable(&e) { - backoff_sleep(attempt).await; + let wait = backoff_duration(attempt); + log_transport_retry( + "messages_streaming", + attempt, + MAX_RETRIES, + &e, + wait, + ); + tokio::time::sleep(wait).await; continue; } + if is_transport_retryable(&e) { + log_transport_giveup("messages_streaming", MAX_RETRIES, &e); + } return Err(LlmError::Http(e)); } }; @@ -669,6 +690,44 @@ fn is_transport_retryable(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || e.is_request() } +/// Short tag describing the transport failure category, for log output. +fn transport_error_kind(e: &reqwest::Error) -> &'static str { + if e.is_timeout() { + "timeout" + } else if e.is_connect() { + "connect-failed" + } else if e.is_request() { + "request-failed" + } else { + "transport" + } +} + +/// User-visible notice that we hit a transport error and are retrying. +/// Without this, an offline / DNS-broken host looks like kres just hanging. +fn log_transport_retry(label: &str, attempt: u32, max: u32, e: &reqwest::Error, wait: Duration) { + kres_core::async_eprintln!( + "[network] {} attempt={}/{} kind={} error={} — retrying in {:?} (check connectivity to api.anthropic.com)", + label, + attempt + 1, + max + 1, + transport_error_kind(e), + e, + wait, + ); +} + +/// User-visible notice that we exhausted retries on transport errors. +fn log_transport_giveup(label: &str, max: u32, e: &reqwest::Error) { + kres_core::async_eprintln!( + "[network] {} giving up after {} attempts: kind={} error={} — API unreachable, check network / proxy / DNS", + label, + max + 1, + transport_error_kind(e), + e, + ); +} + /// Parse the `retry-after` header. Returns `None` when absent or /// unparseable. Accepts both integer-seconds and HTTP-date forms /// (RFC 7231 §7.1.3). The HTTP-date parser is a tiny local impl — @@ -775,10 +834,6 @@ fn apply_jitter(base: Duration, attempt: u32) -> Duration { Duration::from_secs_f64(scaled) } -async fn backoff_sleep(attempt: u32) { - tokio::time::sleep(backoff_duration(attempt)).await; -} - /// Boxed stream of parsed SSE events; `Err(LlmError)` ends the stream. pub struct StreamHandle { inner: futures::stream::BoxStream<'static, Result>, From 339a2a469d3e8008ec1ce370d5b3727312f4fa03 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 05:47:49 -0700 Subject: [PATCH 38/76] kres-agents: feed the plan into define_goal define_goal runs once per task submission, including pipeline- driven follow-ups spawned from fast/slow agent followups. Its signature accepted only (client, prompt), so those per-task goals were derived from the follow-up query alone with no awareness of which plan step the task served. RCU overnight session 5e4aaecc: 194 define_goal calls went out with no plan field. In the same run ~1.1k mentions of tasks.h/rcu_tasks/srcutiny landed in code.jsonl, yet the audit-rcu-tasks and audit-srcutiny plan steps stayed pending in the final session.json snapshot. The downstream todo_update path has no step_id anchor on the emitted todos to roll up; whether that is the whole explanation for the bookkeeping gap has not been traced end-to-end. check_goal already takes Option<&Plan> (goal.rs) and inserts it into its request; mirror the same pattern in define_goal by extracting build_define_goal_request() and teaching the prompt to use the plan as scoping context only, explicitly telling the agent to ignore it when the query is a new topic with no plan-step overlap. Session.rs passes the current plan snapshot on the single call site. Two unit tests in goal.rs cover the round-trip: the plan key is embedded when Some(plan) is passed and absent when None. No functional change when plan is None (operator-typed top-level prompts before any plan exists). Signed-off-by: Chris Mason --- kres-agents/src/goal.rs | 87 +++++++++++++++++++++++++++++++++++++++- kres-repl/src/session.rs | 9 ++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index ed09d2d..03b585d 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -102,8 +102,20 @@ struct PlanResponse { /// the agent fails to produce a well-shaped response — callers /// should treat "no goal" as "run until --turns or the todo list /// drains" and NOT invoke `check_goal` ( behaviour). -pub async fn define_goal(gc: &GoalClient, prompt: &str) -> Option { - let request = json!({ +/// +/// `plan` is the manager's current plan, when one exists. Forwarded +/// to the agent so per-task goals derived from pipeline-driven +/// follow-up prompts can be framed in terms of which plan step the +/// task is serving. Without it, `define_goal` sees only the bare +/// follow-up query and produces goals that read like isolated +/// sub-questions — the downstream check_goal / todo_update path +/// then has no handle to attribute the completed work back to its +/// parent step, so step status stays `pending` even after +/// substantial exploration (observed on the RCU overnight run: +/// ~1.1k `tasks.h`/`rcu_tasks` mentions in code.jsonl, yet the +/// `audit-rcu-tasks` step stayed pending in session.json). +fn build_define_goal_request(prompt: &str, plan: Option<&kres_core::Plan>) -> serde_json::Value { + let mut request = json!({ "task": "define_goal", "query": prompt.chars().take(2000).collect::(), "instructions": "Define a clear, specific goal for this query \ @@ -129,10 +141,37 @@ pub async fn define_goal(gc: &GoalClient, prompt: &str) -> Option, +) -> Option { + let request = build_define_goal_request(prompt, plan); let body = serde_json::to_string_pretty(&request).ok()?; let mut cfg = CallConfig::defaults_for(gc.model.clone()) .with_max_tokens(gc.max_tokens) @@ -528,6 +567,50 @@ mod tests { assert!(c.missing.is_empty()); } + fn sample_plan() -> kres_core::Plan { + // Build via JSON round-trip so this module doesn't need a + // chrono dev-dependency just for the created_at field. + serde_json::from_value(json!({ + "prompt": "review rcu", + "goal": "enumerate rcu bugs", + "mode": "analysis", + "steps": [{"id": "audit-rcu-tree-core", "title": "tree.c"}], + "created_at": "2026-04-23T12:00:00Z", + })) + .expect("sample_plan JSON is well-formed") + } + + #[test] + fn define_goal_request_embeds_plan_when_some() { + let plan = sample_plan(); + let r = build_define_goal_request("tree.c CPU hotplug", Some(&plan)); + let obj = r.as_object().unwrap(); + assert_eq!(obj.get("task").and_then(|v| v.as_str()), Some("define_goal")); + let plan_v = obj.get("plan").expect("plan should be embedded"); + assert_eq!( + plan_v.get("prompt").and_then(|v| v.as_str()), + Some("review rcu"), + ); + let step0 = plan_v + .get("steps") + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .unwrap(); + assert_eq!( + step0.get("id").and_then(|v| v.as_str()), + Some("audit-rcu-tree-core"), + ); + } + + #[test] + fn define_goal_request_omits_plan_when_none() { + let r = build_define_goal_request("first prompt, no plan yet", None); + assert!( + r.as_object().unwrap().get("plan").is_none(), + "plan key should be absent when caller passes None", + ); + } + #[test] fn extract_plan_response_with_missing_ids() { // The id-synthesis path lives inline in `define_plan`; unit diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 6fbce80..43686c4 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -1832,9 +1832,16 @@ impl Session { // with multiple concurrent prompts the previous single // session-wide goal overwrote earlier ones and the reaper // checked task-A's analysis against task-B's goal. + // + // Pass the manager's current plan so the per-task goal can + // anchor itself to a named step. Pipeline follow-ups run + // through this same path, so without the plan they'd produce + // goals with no step attribution and the todo_update path + // downstream can't flip the parent step to `done`. + let existing_plan = self.mgr.plan_snapshot().await; let (defined_goal, task_mode): (Option, kres_agents::TaskMode) = if let Some(gc) = &self.goal_client { - match kres_agents::define_goal(gc, &text).await { + match kres_agents::define_goal(gc, &text, existing_plan.as_ref()).await { Some(def) => { kres_core::async_eprintln!( "goal ({}): {}", From a05b6695bba4055444b08557bf025dabcced4bba Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 08:43:46 -0700 Subject: [PATCH 39/76] findings: overhaul to jsondb delta store + prose-to-findings audit The LLM-based merger rewrote the whole list every reap and left a findings-N.json history nothing consumed; prose-described bugs that the slow agent didn't promote to a Finding reached report.md but never findings.json; provenance stamps duplicated the full /review prompt across every record; the rich per-task exposition a /summary run wants sat only in report.md. Replace the merger with a jsondb-backed delta store applied by deterministic Rust rules, add a post-reap fast-agent audit that catches prose-only bugs (search-narrowed, rename-on-collision so false negatives cost at most a duplicate row), stamp provenance as uuid + todo-tag, attach each task's effective_analysis under a new store-local `details` field that summary can read but agents never see, and thread a /stop-driven notify through the audit call so an operator who hits stop mid-run isn't waiting on a dead HTTP round-trip. Signed-off-by: Chris Mason --- CLAUDE.md | 2 +- Cargo.lock | 241 ++- Cargo.toml | 2 +- .../prompts/slow-code-agent-generic.system.md | 3 +- configs/prompts/slow-code-agent.system.md | 8 +- docs/findings-json-format.md | 29 +- docs/findings.md | 114 ++ docs/review-template.md | 5 +- docs/summary.md | 7 +- kres-agents/src/consolidate.rs | 2 + kres-agents/src/lib.rs | 9 +- kres-agents/src/merge.rs | 270 ---- kres-agents/src/pipeline.rs | 11 +- kres-agents/src/promote.rs | 379 +++++ kres-agents/src/prompts/merger.txt | 54 - kres-agents/src/prompts/merger_system.txt | 46 - kres-agents/src/prompts/promote.txt | 55 + kres-agents/src/prompts/promote_system.txt | 42 + kres-core/Cargo.toml | 1 + kres-core/src/findings.rs | 1349 +++++++++++------ kres-core/src/lib.rs | 15 +- kres-core/src/shrink.rs | 2 + kres-core/src/task.rs | 17 + kres-repl/src/report.rs | 2 + kres-repl/src/session.rs | 518 ++++--- kres/src/main.rs | 2 +- 26 files changed, 2134 insertions(+), 1051 deletions(-) create mode 100644 docs/findings.md delete mode 100644 kres-agents/src/merge.rs create mode 100644 kres-agents/src/promote.rs delete mode 100644 kres-agents/src/prompts/merger.txt delete mode 100644 kres-agents/src/prompts/merger_system.txt create mode 100644 kres-agents/src/prompts/promote.txt create mode 100644 kres-agents/src/prompts/promote_system.txt diff --git a/CLAUDE.md b/CLAUDE.md index ea8bce0..1532283 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ Rate limiters are shared across agents that use the same API key string. prompts/ # System prompts + bug-summary.md skills/ # Skill files (kernel.md, …) sessions// # Per-run artifacts when --results not set - findings.json # Cumulative findings (history in findings-N.json) + findings.json # jsondb-backed canonical findings (delta-applied, no history) report.md # Append-only narrative session.json # Plan + todo + deferred + counters (resume state) summary.txt # Output of /summary or kres --summary (summary.md with --summary-markdown) diff --git a/Cargo.lock b/Cargo.lock index ec64565..b582423 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,6 +308,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -368,6 +374,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -487,11 +499,39 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -711,6 +751,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -732,6 +778,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -772,6 +830,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsondb" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "072db990fc11ab81bdfca6ba3f79adb3da8b38217685e35b431f6a097d351036" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "kres" version = "0.1.0" @@ -816,6 +886,7 @@ dependencies = [ "anyhow", "chrono", "dirs", + "jsondb", "serde", "serde_json", "tempfile", @@ -885,6 +956,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.185" @@ -1095,6 +1172,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1174,6 +1261,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -1399,6 +1492,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1835,6 +1934,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1871,6 +1976,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "getrandom 0.4.2", "js-sys", "sha1_smol", "wasm-bindgen", @@ -1903,7 +2009,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -1961,6 +2076,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -1974,6 +2111,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -2284,12 +2433,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index ed0188d..90266b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ chrono = { version = "0.4", features = ["serde"] } # Misc once_cell = "1" dirs = "5" -uuid = { version = "1", features = ["v5"] } +uuid = { version = "1", features = ["v4", "v5"] } # cancellation tokens for graceful shutdown (C2/C3/H7 in bugs.md) tokio-stream = "0.1" diff --git a/configs/prompts/slow-code-agent-generic.system.md b/configs/prompts/slow-code-agent-generic.system.md index c224431..3a1a513 100644 --- a/configs/prompts/slow-code-agent-generic.system.md +++ b/configs/prompts/slow-code-agent-generic.system.md @@ -32,7 +32,8 @@ ANALYSIS — the primary artifact: FINDINGS — only when a bug actually surfaces: - The findings pipeline is live for generic-mode tasks: if in the course of answering the question you spot an actionable bug, emit a Finding. Schema matches the review flow: {id, title, severity (low|medium|high|critical), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. - Do NOT invent findings to "add value". A factual-question task that uncovers no bug emits an empty findings array. The question was the goal; findings are incidental. -- Every bug you describe in 'analysis' prose MUST also appear as a Finding — the merge pass downstream reads ONLY the findings array. A bug that exists only in prose will be LOST. +- Every bug you describe in 'analysis' prose MUST also appear as a Finding — the delta-apply pass downstream reads ONLY the findings array. A bug that exists only in prose will be LOST. +- DELTA SEMANTICS — the findings array is applied as a delta keyed by 'id' by a deterministic Rust pass, not an LLM merger. NEW id appends; EXISTING id (matching a 'previous_findings' entry) updates the existing record in place (union relevant_symbols / relevant_file_sections / related_finding_ids / open_questions, non-empty prose fields overwrite, severity only rises); EXISTING id with "status": "invalidated" flips the existing record to invalidated — use this when new context you just saw makes a prior finding wrong (guard you missed, bound already enforced, ordering actually honoured). Emit ONLY entries you are adding, extending, or invalidating this turn, never the full list. Followup types (same schema the fast agent uses): - "source" / "callers" / "callees" — symbol name diff --git a/configs/prompts/slow-code-agent.system.md b/configs/prompts/slow-code-agent.system.md index 3ff90f4..036f9b1 100644 --- a/configs/prompts/slow-code-agent.system.md +++ b/configs/prompts/slow-code-agent.system.md @@ -34,7 +34,13 @@ PLAN REWRITE — optional top-level `plan` field on the response: FINDINGS — emit native structured records: - Every actionable bug or strong suspect you discover in YOUR lens becomes a Finding record in the 'findings' array. -- PROMOTION RULE: every bug you describe in the 'analysis' prose MUST also appear as a Finding. The merge pass downstream reads ONLY the findings array — prose is for narrative, not for carrying bugs. A bug that exists only in prose will be LOST. Conversely, if a claim isn't solid enough to emit as a Finding with a concrete reproducer_sketch, don't describe it as a bug in prose either; demote it to an observation or a followup. +- PROMOTION RULE: every bug you describe in the 'analysis' prose MUST also appear as a Finding. The delta-apply pass downstream reads ONLY the findings array — prose is for narrative, not for carrying bugs. A bug that exists only in prose will be LOST. Conversely, if a claim isn't solid enough to emit as a Finding with a concrete reproducer_sketch, don't describe it as a bug in prose either; demote it to an observation or a followup. +- DELTA SEMANTICS — the 'findings' array is applied to the running list by a deterministic Rust pass, not an LLM merger. Each entry is a delta keyed by 'id': + - NEW id → appended as a fresh finding, with first_seen_task stamped. + - EXISTING id (matches a 'previous_findings' entry) → the existing record is updated in place: relevant_symbols, relevant_file_sections, related_finding_ids, and open_questions are UNIONED with the incoming. For title / summary / reproducer_sketch / impact / mechanism_detail / fix_sketch, the incoming value wins ONLY when it's at least as long as the existing — a shorter incoming is ignored (protects against a later turn overwriting a detailed body with a one-sentence reminder). Severity is raised, never lowered; last_updated_task is stamped. If you are extending an existing finding, make the incoming prose at least as long as what's on the record or it will be dropped. + - EXISTING id with "status": "invalidated" → the existing record is marked invalidated and stays in the list as negative evidence. USE THIS when new code or context you just saw makes a prior finding wrong (the alleged racy store is behind a lock you missed, the OOB index is already bounded upstream, the ordering contract you thought was violated is actually enforced). Keep the summary empty to preserve the original body verbatim, or write a short incoming summary that explains WHY it's invalid — your call. Do not silently re-propose an invalidated finding unless you have new evidence that reverses the invalidation. + - EXISTING id (invalidated) + "reactivate": true → the existing record flips back to Status::Active. Use this ONLY when you have discovered new code or context that reverses a prior invalidation (e.g. the guard you thought covered the race turns out to be elided under a specific config). Set "reactivate": true on the incoming delta and write a fresh summary explaining the reversal. Without the explicit "reactivate" flag, an incoming "status": "active" on an invalidated record is IGNORED — invalidation is otherwise sticky. + You do NOT return the full list. Emit ONLY the entries you are adding, extending, invalidating, or reactivating this turn. - Per-finding schema: {id (snake_case slug ≤40 chars), title, severity (low|medium|high|critical), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. - 'relevant_symbols' is an array of {name, filename, line, definition} records. Copy the actual source from the 'symbols' field you received — only the ones the reader needs to understand THIS bug. Do NOT copy the whole symbols array. INCLUDE invariant-establishing symbols even when they're not at the bug site: the init / registration function that assigns the function pointer, populates the ring slot, or sets up the single-producer invariant the bug depends on. A reproducer author needs those anchors or wastes time re-deriving them. - 'relevant_file_sections' is an array of {filename, line_start, line_end, content} records for snippets that aren't whole functions (headers, constants, macro tables). Optional if relevant_symbols covers everything. diff --git a/docs/findings-json-format.md b/docs/findings-json-format.md index 46e2848..3187852 100644 --- a/docs/findings-json-format.md +++ b/docs/findings-json-format.md @@ -96,6 +96,7 @@ Rationale: | `mechanism_detail` | string | Specifics that pin down HOW the bug becomes exploitable: which struct-field type/offset gets clobbered, which invariant-establishing ordering contract in adjacent code is violated, what the actual kernel object behind an OOB target is (e.g. `tx_ring[8]` lands on a `tx_int` function pointer). These are the facts a reproducer or patch author would otherwise re-derive. | | `fix_sketch` | string | 1-3 sentences describing a concrete patch the analysis identified (e.g. "cache the static-key result in a local bool at bnxt_xdp.c:353 and use it for both lock and unlock"). Omit entirely if no fix was analyzed — never fabricate. | | `open_questions` | array[string] | Unresolved items that would settle or refine the finding: `[UNVERIFIED]` claims, call sites not yet confirmed, type-query followups, locking-order assumptions, etc. One sentence each. These accumulate across turns; the merger unions them. | +| `details` | array[object] | Per-task narrative captured at apply_delta time. Each entry `{task, analysis}` pairs a provenance stamp with the task's effective_analysis prose verbatim. **Store-local only** — every site that hands findings to an agent must run them through `kres_core::redact_findings_for_agent` first. Consumed by `/summary` so the plain-text summary can reach the richer exposition that would otherwise only live in report.md. Never emitted by agents; the store populates this field. | ## Sizing guidance for embedded bodies @@ -126,13 +127,15 @@ should be JUST enough to prove the bug. Rules of thumb: ## Relationship to other files - `todo.md` (via `--todo`): the plan, what's next. -- `findings-N.json` (via `--findings`): the results, what's been - proven. One numbered file per task turn; the highest N is current. +- `findings.json` (via `--findings`): the results, what's been + proven. Maintained in-place by a jsondb-backed store; each task + reap applies its delta deterministically (no per-turn snapshot + history). - `code.jsonl` / `main.jsonl`: raw inference transcripts. - Report markdown (via `--report`): human narrative, appended per task. The four are complementary and independent — writing -`findings-N.json` doesn't touch the others. +`findings.json` doesn't touch the others. ## Agent interaction @@ -157,14 +160,18 @@ Three points where findings flow: emitted as a Finding by any lens) to new Findings, and returns `(unified_analysis, unified_findings)` for the task. -3. **After each task is reaped, a second fast-agent pass merges the - task's unified findings into the running `current_findings` list**: - handles cross-task dedup, chain-linking via - `related_finding_ids`, status transitions, subset-supersession, - and preservation of `mechanism_detail` / `fix_sketch` / - `open_questions` across merges. This is the pass that writes the - next `findings-N.json` (monotonically incremented; prior turn's - snapshot stays on disk). +3. **After each task is reaped, the task's unified findings are + applied to the running list as a delta by + `kres_core::findings::FindingsStore::apply_delta`**: incoming + records with a matching `id` update the existing finding in + place (union relevant_symbols / relevant_file_sections / + related_finding_ids / open_questions, prefer incoming non-empty + prose, max severity, stamp `last_updated_task`); a matching id + with `status: invalidated` flips the existing entry to + invalidated; a new id is appended with `first_seen_task` stamped. + The store is backed by jsondb and rewrites the canonical + `findings.json` atomically on every apply. There is no per-turn + snapshot history and no LLM round-trip during apply. 4. **Before each slow-agent call**, the slow-agent request includes a `previous_findings` field carrying the current list. The slow diff --git a/docs/findings.md b/docs/findings.md new file mode 100644 index 0000000..7d42deb --- /dev/null +++ b/docs/findings.md @@ -0,0 +1,114 @@ +# How findings move through kres + +Narrative companion to [`findings-json-format.md`](findings-json-format.md). + +## The pipeline + +``` +prompt → fast agent → slow agent → consolidator → reaper → findings.json + (analysis + (per-lens → (applies (jsondb-backed) + findings) unified) delta) + ↓ + report.md +``` + +The slow agent emits a JSON envelope; the reaper turns each reaped +task into storage. + +## The slow agent's `findings` array is a delta + +```json +{"analysis": "...", "findings": [{"id": "race_in_cq_ack", ...}], "followups": [...]} +``` + +Per entry, keyed by `id`: + +- **New `id`** → append. +- **Existing `id`** → merge in place. Populate only the fields that + change. +- **Existing `id` + `"status": "invalidated"`** → mark as negative + evidence; record stays in the store. +- **Existing `id` + `"reactivate": true`** → reverse a prior + invalidation. + +The agent emits only what it's adding, extending, invalidating, or +reactivating this turn — never the whole list. + +## The reaper, per reaped task + +1. Append `effective_analysis` to `report.md` (before the `/stop` + latch check, so a stopped task still captures prose). +2. If `/stop` is latched, bail. +3. Run the **promoter**: a one-shot fast-agent audit that reads + the analysis prose and a prose-narrowed slice of existing + findings, then emits any bugs the prose names that the slow + agent didn't promote to a Finding. Extras are appended to the + delta. +4. `FindingsStore::apply_delta(delta, stamp, Some(&analysis))`. +5. If the promoter contributed entries, append a + `_promoted-from-prose: id1, id2_` trailer to `report.md`. + +The promoter covers two silent-loss paths: a lens describing a bug +in prose without emitting the matching Finding, and a slow-agent +reply whose JSON didn't parse (`ParseStrategy::RawText`). It uses +its own judge-mode system prompt (`PROMOTE_SYSTEM`) and is +cancellable by `/stop` via `tokio::sync::Notify`. + +## Apply rules (deterministic Rust) + +`kres_core::findings::apply_delta_to_list`: + +- **Update (matching id):** union `relevant_symbols`, + `relevant_file_sections`, `related_finding_ids`, `open_questions`. + Prose fields (`title`, `summary`, `reproducer_sketch`, `impact`, + `mechanism_detail`, `fix_sketch`) use **longer-wins** — a shorter + incoming is ignored, so a later turn that mentions the id in + passing can't clobber a detailed earlier body. Severity escalates + only. `reactivate: true` beats contradictory + `status: invalidated` on the same delta. +- **Add (new id):** append with stamps; strip any wire-level + `reactivate` / `details` fields the agent tried to send. +- **Collision at the promoter:** `filter_net_new` sees the full + store ∪ delta universe. A colliding id is **renamed** to + `__promoted_`, never dropped — losing a record is worse + than storing a duplicate. + +## Provenance + +Each task gets a `Uuid::new_v4()` at spawn. Pipeline-dispatched +tasks (`cmd_next` / `cmd_continue`) also carry the dispatching +`TodoItem.id` (or `.name`). `first_seen_task` / +`last_updated_task` are stamped as `"/"` or +bare `""` for operator-typed prompts. + +## The `details` field + +Every `apply_delta` touch attaches a `{task, analysis}` entry to +the finding's `details`. Same task_id overwrites; different +task_ids append. This is how `/summary` reaches the per-task +narrative that would otherwise live only in `report.md`. + +**`details` never goes back to an agent.** Agent-bound slices go +through `kres_core::redact_findings_for_agent` first — applied in +the slow-agent `previous_findings` path and in the promoter's +inputs. An incoming delta that tries to populate `details` itself +is stripped at add-time. + +## Storage + +`FindingsStore` wraps `JsonDb`. Every write-guard +drop atomically rewrites `/findings.json` (tmp + fsync + +rename). One canonical file, no snapshots, no LLM round-trip on +apply. Legacy unversioned `findings.json` files load via +`SchemaV0::VERSION_OPTIONAL = true`. + +## Observability + +The reaper logs per apply: + +``` +[findings] N total (added=A updated=U invalidated=I reactivated=R changed=C quiescent=Q) +``` + +`tasks_since_change` resets only on a structural change (not on +details-only updates) and drives the `--turns 0` quiescence stop. diff --git a/docs/review-template.md b/docs/review-template.md index 4a2fe48..3a93e44 100644 --- a/docs/review-template.md +++ b/docs/review-template.md @@ -52,7 +52,6 @@ To change the lens set, drop a customised copy at adds a `/` slash-command invocable via `--prompt ": target"` or `--prompt "/ target"`. -`--results ` keeps the run's artifacts (`findings.json` -plus `findings-N.json` history, `report.md`, `summary.txt`) -in `/`; without it kres picks +`--results ` keeps the run's artifacts (`findings.json`, +`report.md`, `summary.txt`) in `/`; without it kres picks `~/.kres/sessions//`. diff --git a/docs/summary.md b/docs/summary.md index 58ed572..cffd868 100644 --- a/docs/summary.md +++ b/docs/summary.md @@ -1,9 +1,10 @@ # Summary output — `/summary`, `--summary`, `summary.txt`/`summary.md` After each task, kres appends the slow agent's narrative to -`/report.md` and rewrites `/findings.json` with -the cumulative merged list (the previous canonical file is copied -to `findings-N.json` first, preserving history). +`/report.md` and applies the task's findings delta to the +jsondb-backed `/findings.json`. The canonical file is +rewritten atomically in place (tmp + fsync + rename); there are no +per-turn history snapshots. A plain-text summary is produced by `/summary` (or automatically on `--turns` exit, or standalone via diff --git a/kres-agents/src/consolidate.rs b/kres-agents/src/consolidate.rs index ae47a8c..806e8da 100644 --- a/kres-agents/src/consolidate.rs +++ b/kres-agents/src/consolidate.rs @@ -252,6 +252,8 @@ mod tests { first_seen_task: None, last_updated_task: None, related_finding_ids: vec![], + reactivate: false, + details: vec![], } } diff --git a/kres-agents/src/lib.rs b/kres-agents/src/lib.rs index 9248e21..9ce6804 100644 --- a/kres-agents/src/lib.rs +++ b/kres-agents/src/lib.rs @@ -1,9 +1,13 @@ -//! Agent roles: fast, slow, main, todo, consolidator, merger. +//! Agent roles: fast, slow, main, todo, consolidator. //! //! Phase 4 landed: agent configs, response parsing (prose-then-JSON //! fallback, fenced-block extraction, brace-match), followup types, //! prompt builders. The actual fast/slow pipeline runner is a follow- //! on phase. +//! +//! The cross-task findings merger (LLM-based whole-list rewrite) +//! was retired in favour of deterministic delta application in +//! `kres_core::findings::apply_delta_to_list`. pub mod config; pub mod consolidate; @@ -14,8 +18,8 @@ pub mod followup; pub mod goal; pub mod main_agent; pub mod mcp_fetcher; -pub mod merge; pub mod pipeline; +pub mod promote; pub mod prompt; pub mod prompt_file; pub mod response; @@ -36,7 +40,6 @@ pub use goal::{ pub use kres_core::TaskMode; pub use main_agent::{parse_actions, MainAgent, DEFAULT_MAX_MAIN_TURNS}; pub use mcp_fetcher::{McpFetcher, McpMethodMap}; -pub use merge::{merge_findings, MERGER_SYSTEM}; pub use pipeline::{ ConsolidatorClient, DataFetcher, FetchResult, NullFetcher, Orchestrator, RunContext, TaskSummary, diff --git a/kres-agents/src/merge.rs b/kres-agents/src/merge.rs deleted file mode 100644 index 4ea70de..0000000 --- a/kres-agents/src/merge.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Cross-task findings merge pass. -//! -//! Contract owed to bugs.md#H1: the CALLER is responsible for NOT -//! holding the findings-extract lock across this function. The -//! function itself performs a single fast-agent API call and returns -//! the merged list. Inside kres-core::TaskManager::with_findings_ -//! extract_lock, you should call this function BEFORE taking the -//! lock, then take the lock only for the subsequent disk write. - -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use kres_core::findings::Finding; -use kres_core::log::{LoggedUsage, TurnLogger}; -use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; - -use crate::{error::AgentError, response::parse_code_response}; - -pub const MERGER_INSTRUCTIONS: &str = include_str!("prompts/merger.txt"); - -/// Dedicated system prompt for the merger. The merger used to -/// inherit the fast-code-agent's system prompt (via -/// ConsolidatorClient.system, which is cloned from fast_cfg.system) -/// and rely entirely on MERGER_INSTRUCTIONS embedded in the user -/// message to switch modes. Observed in session cddd1764: -/// occasionally the model ignored the embedded instructions and -/// responded as the fast-code-agent's system prompt directed — -/// emitting {"goal":"..."} shapes or tags — which -/// parse_code_response couldn't lift into a findings list, -/// triggering the empty-list retry. Swapping in a judge-mode -/// system prompt that hard-restricts the merger to {"findings": -/// [...]} eliminates that drift surface. -pub const MERGER_SYSTEM: &str = include_str!("prompts/merger_system.txt"); - -#[derive(Debug, Serialize)] -struct MergeRequest<'a> { - task: &'static str, - task_brief: &'a str, - task_findings: &'a [Finding], - current_findings: &'a [Finding], - instructions: &'a str, -} - -#[derive(Debug, Deserialize)] -struct MergeResponse { - #[serde(default)] - findings: Vec, -} - -pub async fn merge_findings( - client: Arc, - model: Model, - system: Option<&str>, - max_tokens: u32, - task_brief: &str, - task_findings: &[Finding], - current_findings: &[Finding], -) -> Result, AgentError> { - merge_findings_with_logger( - client, - model, - system, - max_tokens, - None, - task_brief, - task_findings, - current_findings, - None, - ) - .await -} - -/// Same as [`merge_findings`] but logs user+assistant turns on the -/// provided TurnLogger's main.jsonl. -#[allow(clippy::too_many_arguments)] -pub async fn merge_findings_with_logger( - client: Arc, - model: Model, - system: Option<&str>, - max_tokens: u32, - max_input_tokens: Option, - task_brief: &str, - task_findings: &[Finding], - current_findings: &[Finding], - logger: Option>, -) -> Result, AgentError> { - // No task-delta → nothing to merge. Skip the API call entirely. - if task_findings.is_empty() { - return Ok(current_findings.to_vec()); - } - - // Cap task_brief at 300 chars. - let brief_capped: String = task_brief.chars().take(300).collect(); - let request = MergeRequest { - task: "merge_findings", - task_brief: &brief_capped, - task_findings, - current_findings, - instructions: MERGER_INSTRUCTIONS, - }; - let request_text = serde_json::to_string(&request)?; - - let mut cfg = CallConfig::defaults_for(model.clone()) - .with_max_tokens(max_tokens) - .with_stream_label("merge findings"); - if let Some(s) = system { - cfg = cfg.with_system(s.to_string()); - } - if let Some(n) = max_input_tokens { - cfg = cfg.with_max_input_tokens(n); - } - - // bugs.md#M2: one retry on transient flake before falling back to - // the deterministic union. Each attempt is a full API call. - // Common case is a single successful call, so the tail cache - // almost never gets a second reader — skip the +25% write tax. - let messages = vec![Message { - role: "user".into(), - content: request_text, - cache: false, - cached_prefix: None, - }]; - for attempt in 0..2 { - if attempt > 0 { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - } - if let Some(lg) = &logger { - lg.log_main("user", &messages[0].content, None, None); - } - let resp_result = client.messages_streaming(&cfg, &messages).await; - let resp = match resp_result { - Ok(r) => r, - Err(e) => { - tracing::warn!( - target: "kres_agents", - attempt, - "merge_findings api call failed: {e}" - ); - continue; - } - }; - let text = extract_text(&resp); - if let Some(lg) = &logger { - lg.log_main( - "assistant", - &text, - Some(LoggedUsage { - input: resp.usage.input_tokens, - output: resp.usage.output_tokens, - cache_creation: resp.usage.cache_creation_input_tokens, - cache_read: resp.usage.cache_read_input_tokens, - }), - None, - ); - } - let parsed = parse_code_response(&text); - if !parsed.findings.is_empty() { - return Ok(parsed.findings); - } - if let Ok(r) = serde_json::from_str::(&text) { - if !r.findings.is_empty() { - return Ok(r.findings); - } - } - tracing::warn!( - target: "kres_agents", - attempt, - "merge_findings parsed to empty list, retrying" - ); - } - // Never silently drop the task delta because the merge failed. - // Union the inputs as a safe fallback so operators can reconcile. - tracing::warn!( - target: "kres_agents", - "merge_findings fell back to deterministic union after retries" - ); - Ok(naive_union(current_findings, task_findings)) -} - -/// Deterministic fallback: current ∪ task (task-wins on id collision). -pub fn naive_union(current: &[Finding], task: &[Finding]) -> Vec { - use std::collections::BTreeMap; - let mut by_id: BTreeMap = BTreeMap::new(); - // Preserve order: current first, then task overrides. - let mut order: Vec = Vec::new(); - for f in current { - if by_id.insert(f.id.clone(), f.clone()).is_none() { - order.push(f.id.clone()); - } - } - for f in task { - if by_id.insert(f.id.clone(), f.clone()).is_none() { - order.push(f.id.clone()); - } - } - order - .into_iter() - .filter_map(|id| by_id.remove(&id)) - .collect() -} - -fn extract_text(resp: &kres_llm::request::MessagesResponse) -> String { - let mut out = String::new(); - for block in &resp.content { - if let kres_llm::request::ContentBlock::Text { text } = block { - out.push_str(text); - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use kres_core::findings::{Severity, Status}; - - fn f(id: &str) -> Finding { - Finding { - id: id.to_string(), - title: id.to_string(), - severity: Severity::Low, - status: Status::Active, - relevant_symbols: vec![], - relevant_file_sections: vec![], - summary: String::new(), - reproducer_sketch: "r".into(), - impact: "i".into(), - mechanism_detail: None, - fix_sketch: None, - open_questions: vec![], - first_seen_task: None, - last_updated_task: None, - related_finding_ids: vec![], - } - } - - #[tokio::test] - async fn empty_task_returns_current_unchanged() { - let c = Arc::new(Client::new("sk-unused").unwrap()); - let current = vec![f("a"), f("b")]; - let out = merge_findings(c, Model::opus_4_7(), None, 16_000, "brief", &[], ¤t) - .await - .unwrap(); - let ids: Vec<&str> = out.iter().map(|f| f.id.as_str()).collect(); - assert_eq!(ids, vec!["a", "b"]); - } - - #[test] - fn naive_union_task_wins_on_id_collision() { - let mut current = vec![f("a"), f("b")]; - current[0].summary = "current-a".into(); - let mut task = vec![f("a"), f("c")]; - task[0].summary = "task-a".into(); - let out = naive_union(¤t, &task); - let ids: Vec<&str> = out.iter().map(|f| f.id.as_str()).collect(); - // Order: a (task-overwrites) — preserving current's slot, b, c. - assert_eq!(ids, vec!["a", "b", "c"]); - assert_eq!(out[0].summary, "task-a"); - } - - #[test] - fn naive_union_preserves_current_only_entries() { - let current = vec![f("a"), f("b")]; - let task: Vec = vec![]; - let out = naive_union(¤t, &task); - let ids: Vec<&str> = out.iter().map(|f| f.id.as_str()).collect(); - assert_eq!(ids, vec!["a", "b"]); - } -} diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index 47021bb..86de519 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -518,11 +518,15 @@ impl Orchestrator { } // Slow agent call. + // Redact `details` before ANY budget / shipping step — the + // per-task narrative stored on Finding.details is for + // /summary only and must never reach an agent prompt. // bugs.md#L5: budget previous_findings to ~1M chars before // shipping. Symbols and context are also trimmed — a single // fetcher response can blow the per-slot budget even with a // short gather loop (e.g. a multi-MB type definition). - let trimmed_prev = shrink_findings_to_budget(&ctx.previous_findings, 1_000_000); + let redacted_prev = kres_core::redact_findings_for_agent(&ctx.previous_findings); + let trimmed_prev = shrink_findings_to_budget(&redacted_prev, 1_000_000); let trimmed_symbols = shrink_json_list_to_budget(&symbols, 1_000_000); let trimmed_context = shrink_json_list_to_budget(&context, 1_000_000); // §cache: same split policy on the slow-agent user message. @@ -910,7 +914,10 @@ impl Orchestrator { } let lens_prompt = format!("{prompt}\n\n{lens_extra}"); // bugs.md#L5: same trim applies in the lens path. - let trimmed_prev = shrink_findings_to_budget(&ctx.previous_findings, 1_000_000); + // Redact `details` first — per-task narrative is + // /summary-only and never reaches a lens agent. + let redacted_prev = kres_core::redact_findings_for_agent(&ctx.previous_findings); + let trimmed_prev = shrink_findings_to_budget(&redacted_prev, 1_000_000); let trimmed_symbols = shrink_json_list_to_budget(&symbols, 1_000_000); let trimmed_context = shrink_json_list_to_budget(&context, 1_000_000); let mut lens_cp = CodePrompt::new(&lens_prompt) diff --git a/kres-agents/src/promote.rs b/kres-agents/src/promote.rs new file mode 100644 index 0000000..f7a4fae --- /dev/null +++ b/kres-agents/src/promote.rs @@ -0,0 +1,379 @@ +//! Prose-to-findings promotion pass. +//! +//! Closes two silent-loss gaps in the pipeline: +//! +//! 1. The slow-agent / consolidator PROMOTION RULE is instructional +//! only. If a lens or the consolidator describes a bug in prose +//! but forgets to emit the matching Finding, the bug reaches +//! report.md + the accumulated ledger but never enters +//! `findings.json`. +//! 2. When a slow-agent or consolidator response has no parseable +//! JSON, `parse_code_response` falls back to +//! `ParseStrategy::RawText`, setting `analysis = text` and +//! `findings = []`. Every bug the model described in that text +//! is lost to the findings pipeline. (The per-slow-call +//! translation at pipeline.rs handles RawText too, so this path +//! is a belt-and-braces catch.) +//! +//! This pass runs once per reaped Analysis/Generic task, after all +//! the slow-agent and consolidator work is done, with the task's +//! effective analysis prose + a prose-relevant narrowing of the +//! current findings universe as input. It returns ONLY the +//! net-new findings — the reaper extends the task's delta with +//! these before handing it to `FindingsStore::apply_delta`. +//! +//! Failure-mode hierarchy (best → worst): +//! - Network error, empty prose, parse failure → empty promotion +//! list, no bug added. +//! - Promoter hits a real prose-only bug but emits an id that +//! collides with an entry the search narrowing missed → +//! `filter_net_new` renames the id to `__promoted_` and +//! lets it through. Cost is a duplicate row in `findings.json` +//! that a human can reconcile. +//! - Only empty ids are ever dropped — there's no useful record +//! to keep in that case. +//! +//! Losing a finding to a silent drop is NOT on the failure list: +//! we'd rather store a duplicate than miss. + +use std::sync::Arc; + +use serde::Serialize; +use tokio::sync::Notify; + +use kres_core::findings::Finding; +use kres_core::log::{LoggedUsage, TurnLogger}; +use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; + +use crate::{ + error::AgentError, + response::{parse_code_response, ParseStrategy}, +}; + +pub const PROMOTE_INSTRUCTIONS: &str = include_str!("prompts/promote.txt"); + +/// Dedicated system prompt for the promoter. Mirrors the reasoning +/// of the retired `merger_system.txt`: inheriting the fast-code- +/// agent's system prompt pushes the model toward the fast-agent +/// schema (ready_for_slow / skill_reads / tags), which +/// parse_code_response can't lift into a findings list. A judge- +/// mode system that hard-restricts output to `{"findings": [...]}` +/// removes that drift surface. The call is already paid for; the +/// dedicated system adds zero network cost. +pub const PROMOTE_SYSTEM: &str = include_str!("prompts/promote_system.txt"); + +#[derive(Debug, Serialize)] +struct PromoteRequest<'a> { + task: &'static str, + task_brief: &'a str, + existing_findings: &'a [Finding], + analysis: &'a str, + instructions: &'a str, +} + +/// Run the promotion pass against a configured fast-agent client. +/// +/// - `prose_relevant_existing`: the findings sent to the LLM as +/// `existing_findings`. Callers should narrow this via +/// [`kres_core::relevant_subset`] so the prompt doesn't balloon +/// with findings the audit can't plausibly dedup against. It is +/// always safe to pass the full store here — you just pay the +/// tokens. +/// - `dedup_against`: the universe of known ids used by the +/// post-response filter. Callers should pass the FULL store ∪ +/// delta here, regardless of how aggressively the LLM-bound list +/// was narrowed. The filter renames colliding ids; it doesn't +/// drop, so a false-negative in the narrowing never costs us a +/// finding — it costs a duplicate row a human can reconcile. +/// - `cancel`: when `Some`, the HTTP round-trip is wrapped in a +/// `tokio::select!` on `notify.notified()`. A `notify_waiters()` +/// from the REPL's /stop handler abandons the call and returns +/// an empty extras list. Pass `None` from tests or call sites +/// that don't need operator-driven cancellation. +/// +/// Returns the NET-NEW findings discovered in the prose (with any +/// colliding id renamed to `__promoted_`). Returns an empty +/// list when cancelled — abandonment is a safe, non-fatal outcome. +#[allow(clippy::too_many_arguments)] +pub async fn promote_prose_bugs_with_logger( + client: Arc, + model: Model, + system: Option<&str>, + max_tokens: u32, + max_input_tokens: Option, + task_brief: &str, + analysis: &str, + prose_relevant_existing: &[Finding], + dedup_against: &[Finding], + cancel: Option>, + logger: Option>, +) -> Result, AgentError> { + // Prose nothing to audit. + if analysis.trim().is_empty() { + return Ok(vec![]); + } + + // Cap task_brief like the consolidator does so a long operator + // prompt doesn't dominate the context window. + let brief_capped: String = task_brief.chars().take(300).collect(); + let request = PromoteRequest { + task: "promote_prose_bugs", + task_brief: &brief_capped, + existing_findings: prose_relevant_existing, + analysis, + instructions: PROMOTE_INSTRUCTIONS, + }; + let request_text = serde_json::to_string(&request)?; + + let mut cfg = CallConfig::defaults_for(model) + .with_max_tokens(max_tokens) + .with_stream_label("promote prose"); + if let Some(s) = system { + cfg = cfg.with_system(s.to_string()); + } + if let Some(n) = max_input_tokens { + cfg = cfg.with_max_input_tokens(n); + } + + // One-shot per task — tail cache would never be read. + let messages = vec![Message { + role: "user".into(), + content: request_text, + cache: false, + cached_prefix: None, + }]; + if let Some(lg) = &logger { + lg.log_code("user", &messages[0].content, None, None); + } + let resp = match cancel.clone() { + Some(notify) => tokio::select! { + biased; + _ = notify.notified() => { + tracing::info!( + target: "kres_agents", + "promote pass cancelled mid-call" + ); + return Ok(vec![]); + } + r = client.messages_streaming(&cfg, &messages) => r, + }, + None => client.messages_streaming(&cfg, &messages).await, + } + .map_err(|e| AgentError::Other(e.to_string()))?; + + let text = extract_text(&resp); + if let Some(lg) = &logger { + lg.log_code( + "assistant", + &text, + Some(LoggedUsage { + input: resp.usage.input_tokens, + output: resp.usage.output_tokens, + cache_creation: resp.usage.cache_creation_input_tokens, + cache_read: resp.usage.cache_read_input_tokens, + }), + None, + ); + } + + let parsed = parse_code_response(&text); + // A RawText strategy on the promoter's OWN reply means the + // dedicated PROMOTE_SYSTEM judge-mode prompt didn't hold — the + // model emitted free-form prose instead of the required + // `{"findings":[...]}` shape. We still degrade to an empty + // extras list (safe), but the drift is worth a warning: if it + // fires repeatedly the prompt (or the model) needs attention. + // bytes_out is included so operators can spot a huge silent + // dump vs a truly empty reply. + if parsed.strategy == ParseStrategy::RawText { + tracing::warn!( + target: "kres_agents", + bytes_out = text.len(), + "promoter reply had no parseable JSON; PROMOTE_SYSTEM drift suspected, returning empty" + ); + } + Ok(filter_net_new(parsed.findings, dedup_against)) +} + +/// Ensure every promoted Finding has an id distinct from both the +/// `existing` set and every other entry in `promoted`. On a +/// collision, RENAME the id by appending a `__promoted_` suffix +/// rather than dropping the record. Empty ids are still dropped — +/// there's no useful bug to keep. +/// +/// Policy rationale: it is much better to store a duplicate than to +/// miss a finding. Once we start narrowing the `existing` universe +/// by prose-relevance (to shrink the prompt), a search miss would +/// leave the promoter unaware of a store entry and free to re-emit +/// its id. Dropping on collision would then LOSE the promoted bug. +/// Renaming keeps the record, at the cost of a duplicate row that a +/// human reviewer or a later cleanup pass can reconcile. +/// +/// `apply_delta_to_list` matches ids against the full store, so a +/// renamed id always lands as a fresh append; the original store +/// entry is untouched. +fn filter_net_new(promoted: Vec, existing: &[Finding]) -> Vec { + use std::collections::BTreeSet; + let mut seen: BTreeSet = existing.iter().map(|f| f.id.clone()).collect(); + let mut out = Vec::with_capacity(promoted.len()); + for mut p in promoted { + if p.id.is_empty() { + continue; + } + if seen.contains(&p.id) { + let original = p.id.clone(); + let mut suffix = 2u32; + loop { + let candidate = format!("{original}__promoted_{suffix}"); + if !seen.contains(&candidate) { + p.id = candidate; + break; + } + suffix += 1; + } + } + seen.insert(p.id.clone()); + out.push(p); + } + out +} + +fn extract_text(resp: &kres_llm::request::MessagesResponse) -> String { + let mut out = String::new(); + for block in &resp.content { + if let kres_llm::request::ContentBlock::Text { text } = block { + out.push_str(text); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kres_core::findings::{Severity, Status}; + + fn f(id: &str) -> Finding { + Finding { + id: id.to_string(), + title: id.to_string(), + severity: Severity::Medium, + status: Status::Active, + relevant_symbols: vec![], + relevant_file_sections: vec![], + summary: "s".into(), + reproducer_sketch: "r".into(), + impact: "i".into(), + mechanism_detail: None, + fix_sketch: None, + open_questions: vec![], + first_seen_task: None, + last_updated_task: None, + related_finding_ids: vec![], + reactivate: false, + details: vec![], + } + } + + #[test] + fn filter_renames_ids_already_in_existing() { + // Losing a record is worse than storing a duplicate — a + // collision gets a __promoted_ suffix, not a drop. + let existing = vec![f("a"), f("b")]; + let promoted = vec![f("a"), f("c"), f("b"), f("d")]; + let out = filter_net_new(promoted, &existing); + let ids: Vec<&str> = out.iter().map(|x| x.id.as_str()).collect(); + assert_eq!(ids, vec!["a__promoted_2", "c", "b__promoted_2", "d"]); + } + + #[test] + fn filter_renames_within_promoted_output() { + // Two promoted entries sharing an id also get renamed so + // both records survive into the store. + let out = filter_net_new(vec![f("c"), f("c"), f("d")], &[]); + let ids: Vec<&str> = out.iter().map(|x| x.id.as_str()).collect(); + assert_eq!(ids, vec!["c", "c__promoted_2", "d"]); + } + + #[test] + fn filter_renames_with_escalating_suffix_when_needed() { + // Collision with a pre-existing `x__promoted_2` must escalate + // past 2 rather than re-colliding. + let mut pre = f("x__promoted_2"); + pre.title = "pre-existing renamed".into(); + let existing = vec![f("x"), pre]; + let promoted = vec![f("x")]; + let out = filter_net_new(promoted, &existing); + let ids: Vec<&str> = out.iter().map(|x| x.id.as_str()).collect(); + assert_eq!(ids, vec!["x__promoted_3"]); + } + + #[test] + fn filter_drops_empty_ids() { + // Empty id still drops — there's no useful record to keep. + let mut weird = f(""); + weird.title = "no id".into(); + let out = filter_net_new(vec![weird, f("legit")], &[]); + let ids: Vec<&str> = out.iter().map(|x| x.id.as_str()).collect(); + assert_eq!(ids, vec!["legit"]); + } + + #[tokio::test] + async fn empty_analysis_returns_empty_without_api_call() { + // The function must short-circuit on empty prose so we don't + // waste an API round-trip on no-op inputs. + let c = Arc::new(Client::new("sk-unused").unwrap()); + let out = promote_prose_bugs_with_logger( + c, + Model::opus_4_7(), + None, + 8_000, + None, + "brief", + "", + &[], + &[], + None, + None, + ) + .await + .unwrap(); + assert!(out.is_empty()); + } + + #[tokio::test] + async fn cancel_before_http_roundtrip_short_circuits() { + // When /stop fires (notify.notify_waiters()) before + // messages_streaming can resolve, the promoter must return + // Ok(vec![]) immediately. We pre-notify so the select!'s + // `biased` branch wins deterministically — the real HTTP + // call with sk-unused would otherwise fail with an auth + // error after a network round-trip. Combined with `biased`, + // this test runs synchronously after the notify without + // making any network traffic. + let notify = Arc::new(tokio::sync::Notify::new()); + // notify_waiters() only wakes currently-registered waiters; + // to guarantee the select! sees a pending notification we + // pre-permit via notify_one() which stores a permit for the + // next notified() call. biased ordering still prefers the + // cancel branch. + notify.notify_one(); + let c = Arc::new(Client::new("sk-unused").unwrap()); + let out = promote_prose_bugs_with_logger( + c, + Model::opus_4_7(), + None, + 8_000, + None, + "brief", + "some prose naming cpu_mask in lib/cpumask.c:42", + &[], + &[], + Some(notify), + None, + ) + .await + .unwrap(); + assert!(out.is_empty(), "cancel path must return an empty extras list"); + } +} diff --git a/kres-agents/src/prompts/merger.txt b/kres-agents/src/prompts/merger.txt deleted file mode 100644 index 7687249..0000000 --- a/kres-agents/src/prompts/merger.txt +++ /dev/null @@ -1,54 +0,0 @@ -Merge a task's 'task_findings' into 'current_findings'. Return the -COMPLETE updated findings list (not a diff). See -findings-json-format.md for the schema. - -Return JSON only, no fences, no preamble: -{"findings": [, ...]} - -Slow agents already produced structured Finding records per lens; a -separate cross-lens pass already deduped them within this task. Your -job is cross-TASK merging. - -SCHEMA MIGRATION — some entries in 'current_findings' may use an -older schema with 'files': ["path:line", ...] and 'symbols': [name, -...] instead of 'relevant_symbols' / 'relevant_file_sections'. Treat -missing relevant_* fields as empty. When you emit a merged entry, -populate relevant_symbols / relevant_file_sections with whatever -embedded code is now available — do not propagate the old 'files' / -'symbols' string lists (the extractor that produced them no longer -runs). - -MERGE RULES: -- If a task_findings entry shares an id with an entry in - current_findings, UPDATE the existing entry in place: extend - summary with new detail, union relevant_symbols and - relevant_file_sections (dedup by filename+line), union - related_finding_ids, union open_questions (dedup by string match), - update last_updated_task. Preserve mechanism_detail and - fix_sketch from whichever side has them; if both sides have - distinct content, concatenate with '; '. Keep the HIGHEST - severity. -- PRESERVE FIELDS: mechanism_detail, fix_sketch, and open_questions - are OPTIONAL but load-bearing when present. Never drop them during - merge. If the incoming task finding lacks them but the existing - one has them, keep the existing values. -- If a task_findings entry describes the same bug as an existing - entry but with a different id (overlapping relevant_symbols and - impact), merge under the existing id and drop the new one. -- If an existing finding is a strict subset of a task_findings entry - (broader relevant_symbols, same impact), replace the subset with - the broader one but preserve the existing id for citation - stability. -- Keep every existing finding that isn't being superseded, - invalidated, or merged — including status=invalidated records. - -CHAIN RULES: -- When two findings combine into a larger exploit, populate - related_finding_ids on BOTH with each other's ids. -- If this task's findings demonstrate a chain across pre-existing - findings that weren't already linked, add the chain via - related_finding_ids. You MAY emit a NEW composite finding whose - related_finding_ids lists its components, with its own summary, - reproducer_sketch, impact, and merged relevant_symbols. - -Return the COMPLETE list of findings. diff --git a/kres-agents/src/prompts/merger_system.txt b/kres-agents/src/prompts/merger_system.txt deleted file mode 100644 index 3444681..0000000 --- a/kres-agents/src/prompts/merger_system.txt +++ /dev/null @@ -1,46 +0,0 @@ -You are a cross-task findings merger for a code analysis pipeline. -You do NOT run tools. You do NOT fetch data. You do NOT dispatch -actions. You receive a single JSON user message and return ONE JSON -object matching the shape the message describes. - -The message will carry `"task":"merge_findings"` along with -`current_findings`, `task_findings`, and embedded `instructions` -that tell you exactly how to merge. Read those instructions; they -describe the merge rules, schema-migration handling, and edge -cases. Your reply is always: - - {"findings": [, ...]} - -HARD CONSTRAINTS — violations are bugs: - -- Output JSON ONLY. No preamble, no prose, no fences, no trailing - "done" or other chatter. Start your reply with `{` and end with - `}`. - -- Return the merged `findings` list in full, not a diff, not a - patch. The caller replaces its state with whatever you return. - -- NEVER emit ``, ``, or any XML/tag-wrapped JSON. - Those are main-agent dispatch instructions. You are NOT the main - agent. Your output has no dispatcher; action-tagged text is - discarded. - -- NEVER reply with the single word `done`. That is a main-agent - no-more-tool-calls idiom. You are a merger, not a fetcher. - -- NEVER return a `{"goal": "..."}` or `{"analysis": "..."}` object. - Those are the goal agent's and code agent's shapes. Returning - them here is a bug — the merger ALWAYS returns - `{"findings":[...]}`. - -- NEVER run tools yourself. No grep, read, semcode, git, find. - Your input is the finding records already supplied in the user - message; operate on that payload exactly. - -- Keep the full list of existing findings unless the embedded merge - rules tell you to supersede, invalidate, or combine them. - -If the input violates the expected shape (missing -`current_findings` or `task_findings`), still return the best -`{"findings":[...]}` you can from what was provided. When -task_findings is empty, return current_findings verbatim. diff --git a/kres-agents/src/prompts/promote.txt b/kres-agents/src/prompts/promote.txt new file mode 100644 index 0000000..f26f878 --- /dev/null +++ b/kres-agents/src/prompts/promote.txt @@ -0,0 +1,55 @@ +You are auditing a code-analysis task for COMPLETENESS. The slow +agent and consolidator are supposed to emit every actionable bug as +a structured Finding, but sometimes a bug ends up in the narrative +prose without a matching Finding record. Your job is to catch those +prose-only bugs and promote them so the findings store isn't missing +anything. + +You receive: + - task_brief: what the task was about + - analysis: the prose narrative the task produced + - existing_findings: Finding records already emitted for THIS task + (possibly empty — a RawText parse fallback or a consolidator that + skipped the completeness check can leave this list empty even + when the prose describes concrete bugs) + +For each actionable bug the analysis describes that is NOT covered +by an existing_findings entry, emit a NEW Finding in the output +array. A bug is "covered" when an existing Finding refers to +substantially the same failure mode and code location. + +STRICT RULES: + - Pull summary / reproducer_sketch / impact / relevant_symbols + DIRECTLY from the prose. Quote the file:line snippets the prose + already cites. Do NOT invent code paths, callers, or contracts + that are not in the analysis. + - If the prose is too thin to produce a concrete reproducer_sketch + (no code path + inputs + state that trigger the bug), DO NOT + promote. Skipping is always better than fabricating. + - Skip vague observations ("this looks fragile", "might want to + review X"): only bugs with a concrete failure mode and a named + target get promoted. + - Keep each Finding small: a focused bug, not a section summary. + - Use a snake_case slug id (≤40 chars) that's distinct from every + existing_findings id. + - Do NOT re-emit an existing finding, even with small wording + tweaks. If the existing one is under-specified, leave it alone — + downstream updates will flow through the slow agent, not this + audit pass. + +Per-Finding schema (same as the main pipeline): + {id, title, severity (low|medium|high|critical), + status ('active'), + relevant_symbols: [{name, filename, line, definition}], + relevant_file_sections: [{filename, line_start, line_end, content}], + summary, reproducer_sketch, impact, + mechanism_detail (optional), fix_sketch (optional), + open_questions (optional), + related_finding_ids (optional)} + +Return JSON only, no fences, no preamble: +{"findings": [, ...]} + +An empty list is valid and expected whenever every prose-mentioned +bug is already represented in existing_findings. Do not force +promotions just to produce non-empty output. diff --git a/kres-agents/src/prompts/promote_system.txt b/kres-agents/src/prompts/promote_system.txt new file mode 100644 index 0000000..aa6e4a4 --- /dev/null +++ b/kres-agents/src/prompts/promote_system.txt @@ -0,0 +1,42 @@ +You are a prose-to-findings audit agent for a code analysis +pipeline. You do NOT run tools. You do NOT fetch data. You do NOT +dispatch actions. You do NOT emit followups, plans, goals, or +ready_for_slow flags. You receive a single JSON user message and +return ONE JSON object matching the shape the message describes. + +The message will carry `"task":"promote_prose_bugs"` along with +`task_brief`, `existing_findings`, `analysis`, and embedded +`instructions` that tell you exactly what to audit. Read those +instructions; they describe the rule for promoting a prose-only +bug, the rule for skipping thin prose, and the duplicate-id +constraint. Your reply is always: + + {"findings": [, ...]} + +HARD CONSTRAINTS — violations are bugs: + +- Output JSON ONLY. No preamble, no prose, no fences, no trailing + "done" or other chatter. Start your reply with `{` and end with + `}`. + +- Return ONLY a `findings` array. Do not emit `analysis`, + `followups`, `skill_reads`, `ready_for_slow`, `code_output`, + `code_edits`, `plan`, `goal`, ``, or any other field or + tag. The caller ignores anything that isn't a Finding record. + +- Return NEW findings only — bugs described in the prose that are + NOT already represented in `existing_findings`. On a colliding + id the caller RENAMES your entry by appending `__promoted_` + (to preserve the record in case the narrowing missed a real + match) rather than dropping it. That behaviour is a safety net, + not a license to re-emit: a renamed row still clutters + `findings.json` with a duplicate a human has to reconcile, so + pick fresh ids and skip bugs that already have coverage. + +- Empty arrays are valid and expected. When the prose names no + bug that isn't already covered, emit `{"findings": []}`. + +- Do not fabricate code paths, callers, or contracts that aren't in + the analysis prose. Pull `summary`, `reproducer_sketch`, `impact`, + and `relevant_symbols` directly from what the prose already said. + If the prose is too thin for a concrete Finding, skip it. diff --git a/kres-core/Cargo.toml b/kres-core/Cargo.toml index 4e52a5f..35c7c92 100644 --- a/kres-core/Cargo.toml +++ b/kres-core/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } dirs = { workspace = true } +jsondb = "0.4" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util", "time"] } diff --git a/kres-core/src/findings.rs b/kres-core/src/findings.rs index 6acab01..2ab2fcf 100644 --- a/kres-core/src/findings.rs +++ b/kres-core/src/findings.rs @@ -1,26 +1,29 @@ -//! Structured findings records and the atomic per-turn writer. +//! Findings records and the delta-based store. //! -//! Closes bugs.md items: -//! - H1: `FindingsStore::write_turn` holds its inner mutex ONLY for -//! the N-allocation + disk write. Merge-via-LLM runs outside any -//! store-owned lock. -//! - H2/H3: N allocation and the rename are inside the same critical -//! section, so two concurrent merges can never collide on the same -//! N. -//! - H6: the write is tmp-file + fsync + rename. Partial writes can't -//! leave `findings-N.json` half-written on operator Ctrl-C. +//! Historically each turn rewrote the whole findings list after an +//! LLM-based merge pass. That wastes tokens (the merge prompt carries +//! the full prior list) and disk (a `findings-N.json` snapshot per +//! turn). The new model: //! -//! The schema mirrors findings-json-format.md exactly, including the -//! three optional fields (mechanism_detail, fix_sketch, open_questions) -//! that got lifted out of report-only prose recently. +//! - Slow agents (and every other inference call that emits findings) +//! produce a `findings` array that is interpreted as a DELTA: +//! matching-id entries update an existing finding, new ids add, +//! and `status: invalidated` on an existing id marks it. +//! - The store applies the delta with deterministic Rust rules, no +//! LLM round-trip. See [`FindingsStore::apply_delta`]. +//! - Persistence is handed to the `jsondb` crate: every write guard +//! drop atomically writes the canonical `findings.json`. No more +//! `findings-N.json` history. +//! +//! The canonical on-disk schema mirrors [`FindingsFile`] exactly, +//! wrapped in jsondb's top-level `version` field. use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::Arc; use chrono::{DateTime, Utc}; +use jsondb::{JsonDb, SchemaV0}; use serde::{Deserialize, Serialize}; -use std::io::Write as _StdIoWrite; -use tokio::io::AsyncWriteExt; use thiserror::Error; @@ -32,14 +35,14 @@ pub enum FindingsError { #[error("json error: {0}")] Json(#[from] serde_json::Error), + #[error("jsondb error: {0}")] + JsonDb(#[from] jsondb::Error), + #[error("base findings path {0} has no parent directory")] NoParent(PathBuf), - - #[error("findings base filename must be like foo.json (got {0:?})")] - BadBaseName(PathBuf), } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "lowercase")] pub enum Severity { Low, @@ -73,6 +76,28 @@ pub struct RelevantFileSection { pub content: String, } +/// Per-task narrative detail captured on a Finding. Each entry is +/// the full analysis prose produced by one task that touched this +/// finding (either on its introductory add or on a subsequent +/// update). Consumed by `/summary` so the plain-text summary can +/// pull richer exposition than the short `summary` / +/// `reproducer_sketch` / `impact` fields carry. +/// +/// These entries are NEVER forwarded to another LLM call. Every +/// site that hands findings to an agent strips the field first — +/// slow-agent `previous_findings`, consolidator lens outputs +/// (which come from freshly-deserialised agent replies and don't +/// carry it anyway), and the promoter's narrowed existing_findings +/// all run through [`Finding::redacted_for_agent`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindingDetail { + /// Provenance stamp. Same format as `last_updated_task` — + /// `"/"` or bare uuid. + pub task: String, + /// The task's effective_analysis prose verbatim. + pub analysis: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Finding { pub id: String, @@ -101,80 +126,94 @@ pub struct Finding { pub last_updated_task: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub related_finding_ids: Vec, + + /// Per-task narrative captured from the task's effective_analysis + /// at apply_delta time. Purely for `/summary` generation — NEVER + /// forwarded to another LLM. Call [`Finding::redacted_for_agent`] + /// before handing findings to any agent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub details: Vec, + + /// Wire-only signal: when `true` on an incoming delta AND the + /// matching-id existing record is `Status::Invalidated`, the + /// existing record flips back to `Status::Active`. Intended for + /// slow-agent turns that discover new evidence reversing a + /// prior invalidation (see slow-code-agent.system.md). Never + /// serialized on stored records — `merge_into` consumes the + /// signal and doesn't propagate it; on a new-id apply the flag + /// is stripped before the entry enters the list. + #[serde(default, skip_serializing_if = "is_false")] + pub reactivate: bool, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +impl Finding { + /// Return a clone suitable for inclusion in an LLM prompt — + /// `details` cleared so the agent doesn't see the per-task + /// narrative captured for /summary. Keep every other field. + pub fn redacted_for_agent(&self) -> Finding { + let mut c = self.clone(); + c.details.clear(); + c + } +} + +/// Apply [`Finding::redacted_for_agent`] to every entry. Convenience +/// for the common case where a whole slice is about to be shipped +/// to an agent. +pub fn redact_findings_for_agent(findings: &[Finding]) -> Vec { + findings.iter().map(Finding::redacted_for_agent).collect() } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct FindingsFile { + #[serde(default)] pub findings: Vec, #[serde(default)] pub updated_at: Option>, + /// Consecutive task reaps that produced no change to the list. + /// Used by `--turns 0` stagnation logic; persisted so a resumed + /// REPL still sees the running counter. #[serde(default)] pub tasks_since_change: u32, - /// Extra breadcrumb beyond the documented schema: the turn number - /// is embedded in the file so a copied-out file is still - /// interpretable (bugs.md#R5). + /// Turn counter. Monotonic across all writes. Useful for logs and + /// for operators eyeballing how much churn a session produced. #[serde(default)] pub turn_n: Option, } -/// Atomic writer + discoverer for `findings-N.json` snapshots. -/// -/// Construct with a base path `/dir/findings.json`. The store writes -/// `findings-1.json`, `findings-2.json`, ..., never the bare base. -/// Current-state lookup scans the parent dir. +impl SchemaV0 for FindingsFile { + /// Legacy findings.json files were written by the pre-jsondb + /// store and have no top-level `version` field. Treat those as V0. + const VERSION_OPTIONAL: bool = true; +} + +/// Delta-based findings store, backed by jsondb. /// -/// bugs.md#H1, #H2, #H3, #H6 all route through this type. +/// Construct with `FindingsStore::new(path).await` pointing at +/// `/findings.json`. The store loads the existing file if +/// present, else starts with an empty list. Every call to +/// [`Self::apply_delta`] applies the delta with deterministic rules +/// and writes the updated file atomically. pub struct FindingsStore { base_path: PathBuf, - /// Dir + stem are precomputed once; write_turn doesn't re-parse - /// every call. - parent_dir: PathBuf, - stem: String, - extension: String, - /// Last allocated turn number (monotonic). - state: Mutex, -} - -#[derive(Debug, Default)] -struct FindingsStoreState { - last_turn: u32, - tasks_since_change: u32, + db: Arc>, } impl FindingsStore { - pub fn new(base_path: impl Into) -> Result { + pub async fn new(base_path: impl Into) -> Result { let base_path: PathBuf = base_path.into(); - let parent_dir = base_path + let parent = base_path .parent() - .ok_or_else(|| FindingsError::NoParent(base_path.clone()))? - .to_path_buf(); - let stem = base_path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| FindingsError::BadBaseName(base_path.clone()))? - .to_string(); - let extension = base_path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("json") - .to_string(); - // bugs.md#L4: preflight that we can create and write in the - // parent directory. Matches the Python `configure_findings` - // gap — there, first write at bare_path raced into a bare - // `except: pass` so the operator never noticed a read-only - // $HOME. Here we fail fast at construction. - std::fs::create_dir_all(&parent_dir)?; - let probe = parent_dir.join(format!("{}.probe.{}", stem, std::process::id())); - let mut f = std::fs::File::create(&probe)?; - f.write_all(b"")?; - drop(f); - let _ = std::fs::remove_file(&probe); + .ok_or_else(|| FindingsError::NoParent(base_path.clone()))?; + std::fs::create_dir_all(parent)?; + let db = JsonDb::::load(base_path.clone()).await?; Ok(Self { base_path, - parent_dir, - stem, - extension, - state: Mutex::new(FindingsStoreState::default()), + db: Arc::new(db), }) } @@ -182,294 +221,447 @@ impl FindingsStore { &self.base_path } - /// Compute the turn-N path relative to this store's base. - pub fn path_for(&self, n: u32) -> PathBuf { - self.parent_dir - .join(format!("{}-{}.{}", self.stem, n, self.extension)) - } - - /// Scan the parent directory and return `(path, N)` for the - /// highest-numbered `-.` present, or None. - pub fn discover_latest(&self) -> Result, FindingsError> { - let prefix = format!("{}-", self.stem); - let suffix = format!(".{}", self.extension); - let mut best: Option<(PathBuf, u32)> = None; - - let entries = match std::fs::read_dir(&self.parent_dir) { - Ok(e) => e, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), - }; - for entry in entries { - let entry = entry?; - let name = entry.file_name(); - let Some(name_s) = name.to_str() else { - continue; - }; - let Some(rest) = name_s.strip_prefix(&prefix) else { - continue; - }; - let Some(num_str) = rest.strip_suffix(&suffix) else { - continue; - }; - let Ok(n) = num_str.parse::() else { - continue; - }; - if best.as_ref().map(|(_, b)| n > *b).unwrap_or(true) { - best = Some((entry.path(), n)); - } - } - Ok(best) - } - - /// Initialise the internal counter from what's on disk. The - /// canonical `findings.json` (when present) wins for the seed - /// findings — it is written last on every turn and reflects the - /// current state. The highest numbered `findings-N.json` still - /// drives `last_turn` so the next write picks N+1. - pub fn bootstrap(&self) -> Result { - let mut guard = self.state.lock().unwrap(); - let latest = self.discover_latest()?; - let last_n = latest.as_ref().map(|(_, n)| *n).unwrap_or(0); - let canonical_exists = self.base_path.exists(); - if canonical_exists { - let raw = std::fs::read_to_string(&self.base_path)?; - let file: FindingsFile = serde_json::from_str(&raw)?; - guard.last_turn = last_n; - guard.tasks_since_change = file.tasks_since_change; - return Ok(InitialState { - path: Some(self.base_path.clone()), - turn_n: last_n, - findings: file.findings, - tasks_since_change: file.tasks_since_change, - }); - } - match latest { - Some((path, n)) => { - let raw = std::fs::read_to_string(&path)?; - let file: FindingsFile = serde_json::from_str(&raw)?; - guard.last_turn = n; - guard.tasks_since_change = file.tasks_since_change; - Ok(InitialState { - path: Some(path), - turn_n: n, - findings: file.findings, - tasks_since_change: file.tasks_since_change, - }) - } - None => Ok(InitialState { - path: None, - turn_n: 0, - findings: Vec::new(), - tasks_since_change: 0, - }), - } + /// Snapshot of the current findings list. + pub async fn snapshot(&self) -> Vec { + self.db.read().await.findings.clone() } - /// Write a new turn snapshot atomically. - /// - /// Steps: - /// 1. Lock, allocate `n = last_turn + 1`, compute `tasks_since_change`, - /// target path. Release state-mutex NOTHING that blocks on I/O. - /// 2. Serialize to bytes. - /// 3. Write to `.tmp`, fsync, rename to target. - /// 4. Re-lock to commit `last_turn = n` and updated counter. - /// - /// bugs.md#H6: the tmp+fsync+rename sequence means a SIGKILL - /// anywhere in the middle either leaves the old `N-1.json` - /// intact or atomically replaces with the new. + /// Full file snapshot, including counters and timestamp. + pub async fn file_snapshot(&self) -> FindingsFile { + self.db.read().await.clone() + } + + pub async fn tasks_since_change(&self) -> u32 { + self.db.read().await.tasks_since_change + } + + pub async fn last_turn(&self) -> u32 { + self.db.read().await.turn_n.unwrap_or(0) + } + + /// Apply an inference-produced delta to the store. /// - /// bugs.md#H1: no network or LLM call happens inside the mutex. - /// Callers run consolidation/merge before handing final findings - /// here. - pub async fn write_turn( + /// Rules: + /// - New id → append, stamp `first_seen_task` / `last_updated_task`. + /// - Existing id, incoming `status: Invalidated` → flip existing to + /// invalidated, preserve the body, take any new summary text. + /// - Existing id, otherwise → merge in place: union relevant + /// symbols / file sections / related_finding_ids / + /// open_questions; prefer incoming non-empty prose fields; keep + /// the max severity; stamp `last_updated_task`. + /// - The returned `merged` list reflects the post-apply state. + /// - `changed` is true iff anything was added, flipped to + /// invalidated, or any field on an existing entry changed. + pub async fn apply_delta( &self, - findings: Vec, - changed: bool, - ) -> Result { - // Write layout (requested 2026-04-20): - // findings.json — canonical, always the latest state - // findings-N.json — history snapshot copied from the - // previous findings.json before we - // overwrite it - // - // Step 1: reserve N and track prior counters so a write - // failure can roll back (bugs.md#H2). Only one `write_turn` - // can pick a given N because the allocation is inside the - // state mutex. - let (n, tasks_since_change, prev_last, prev_tsc) = { - let mut g = self.state.lock().unwrap(); - let prev_last = g.last_turn; - let prev_tsc = g.tasks_since_change; - let n = g.last_turn + 1; - g.last_turn = n; - if changed { - g.tasks_since_change = 0; - } else { - g.tasks_since_change = g.tasks_since_change.saturating_add(1); - } - (n, g.tasks_since_change, prev_last, prev_tsc) - }; - let snapshot_path = self.path_for(n); - let canonical = self.base_path.clone(); - // Both tmp paths include N so two concurrent writers in the - // same process (same PID) never step on each other's tmp - // before the rename. - let tmp = self.parent_dir.join(format!( - "{}.{}.n{}.{}.tmp", - self.stem, - self.extension, - n, - std::process::id() - )); - let snapshot_tmp = self.parent_dir.join(format!( - "{}-{}.{}.{}.tmp", - self.stem, - n, - self.extension, - std::process::id() - )); + delta: &[Finding], + task_id: Option<&str>, + task_analysis: Option<&str>, + ) -> Result { + let mut guard = self.db.write().await; + let counts = apply_delta_to_list(&mut guard.findings, delta, task_id, task_analysis); + let next_turn = guard.turn_n.unwrap_or(0).saturating_add(1); + guard.turn_n = Some(next_turn); + guard.updated_at = Some(Utc::now()); + if counts.changed { + guard.tasks_since_change = 0; + } else { + guard.tasks_since_change = guard.tasks_since_change.saturating_add(1); + } + + let merged = guard.findings.clone(); + let tasks_since_change = guard.tasks_since_change; + // Drop the guard to trigger jsondb's atomic save. + drop(guard); - let file = FindingsFile { - findings, - updated_at: Some(Utc::now()), + Ok(ApplyReport { + merged, + added: counts.added, + updated: counts.updated, + invalidated: counts.invalidated, + reactivated: counts.reactivated, + changed: counts.changed, + turn_n: next_turn, tasks_since_change, - turn_n: Some(n), - }; - let bytes = serde_json::to_vec_pretty(&file)?; - - let roll_back_counters = |me: &FindingsStore, tmps: &[&Path]| { - let mut g = me.state.lock().unwrap(); - g.last_turn = prev_last; - g.tasks_since_change = prev_tsc; - for t in tmps { - let _ = std::fs::remove_file(t); - } - }; - if let Err(e) = tokio::fs::create_dir_all(&self.parent_dir).await { - roll_back_counters(self, &[]); - return Err(FindingsError::Io(e)); - } + }) + } - // Step 2: snapshot the current canonical file to - // findings-N.json BEFORE we overwrite it. This gives us a - // full history of how findings evolved while keeping the - // bare `findings.json` as the always-current record. - // - // We copy into a unique tmp and rename so a crash mid-copy - // never leaves a partial findings-N.json on disk. If the - // canonical file does not yet exist (first turn of a fresh - // session), there is nothing to snapshot. - match tokio::fs::metadata(&canonical).await { - Ok(_) => { - if let Err(e) = tokio::fs::copy(&canonical, &snapshot_tmp).await { - roll_back_counters(self, &[&snapshot_tmp]); - return Err(FindingsError::Io(e)); - } - if let Ok(sf) = tokio::fs::File::open(&snapshot_tmp).await { - let _ = sf.sync_all().await; - } - if let Err(e) = tokio::fs::rename(&snapshot_tmp, &snapshot_path).await { - roll_back_counters(self, &[&snapshot_tmp]); - return Err(FindingsError::Io(e)); +} + +#[derive(Debug, Clone)] +pub struct ApplyReport { + pub merged: Vec, + pub added: u32, + pub updated: u32, + pub invalidated: u32, + /// Count of Invalidated → Active transitions triggered by an + /// incoming delta's `reactivate: true` flag. Distinct from + /// `updated` so an operator eyeballing a run can see the rare + /// case where a prior invalidation was reversed. + pub reactivated: u32, + pub changed: bool, + pub turn_n: u32, + pub tasks_since_change: u32, +} + +#[derive(Debug, Clone, Default)] +pub struct DeltaCounts { + pub added: u32, + pub updated: u32, + pub invalidated: u32, + pub reactivated: u32, + pub changed: bool, +} + +/// Apply a delta to an in-memory findings list using the same rules +/// as [`FindingsStore::apply_delta`]. Exposed so the REPL's no-store +/// path and the store can share one implementation. +pub fn apply_delta_to_list( + current: &mut Vec, + delta: &[Finding], + task_id: Option<&str>, + task_analysis: Option<&str>, +) -> DeltaCounts { + let mut counts = DeltaCounts::default(); + for incoming in delta { + match current.iter().position(|e| e.id == incoming.id) { + Some(idx) => { + let was_invalidated = current[idx].status == Status::Invalidated; + let changed = merge_into(&mut current[idx], incoming, task_id); + record_detail(&mut current[idx], task_id, task_analysis); + if changed { + let is_invalidated = current[idx].status == Status::Invalidated; + if !was_invalidated && is_invalidated { + counts.invalidated += 1; + } else if was_invalidated && !is_invalidated { + counts.reactivated += 1; + } else { + counts.updated += 1; + } + counts.changed = true; } } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => { - roll_back_counters(self, &[&snapshot_tmp]); - return Err(FindingsError::Io(err)); + None => { + let mut new_entry = incoming.clone(); + // `reactivate` is a transient wire signal; don't let + // it persist on a newly-inserted record. Same for any + // stray details an incoming delta tried to carry — + // details is a store-local concept, not a wire + // contract the agents know about. + new_entry.reactivate = false; + new_entry.details.clear(); + if let Some(t) = task_id { + if new_entry.first_seen_task.is_none() { + new_entry.first_seen_task = Some(t.to_string()); + } + new_entry.last_updated_task = Some(t.to_string()); + } + current.push(new_entry); + let last_idx = current.len() - 1; + record_detail(&mut current[last_idx], task_id, task_analysis); + counts.added += 1; + counts.changed = true; } } + } + counts +} - // Step 3: atomic write of the new canonical findings.json. - // tmp → fsync → rename → parent-dir fsync. The parent-dir - // fsync is what actually makes the rename durable on - // ext4/xfs after a power loss (bugs.md#H6). - let mut f = match tokio::fs::File::create(&tmp).await { - Ok(f) => f, - Err(e) => { - roll_back_counters(self, &[&tmp]); - return Err(FindingsError::Io(e)); - } - }; - if let Err(e) = async { - f.write_all(&bytes).await?; - f.flush().await?; - f.sync_all().await +/// Append (or refresh) a `FindingDetail` entry on `finding` carrying +/// this task's analysis prose. No-op when either `task_id` or +/// `task_analysis` is None / empty. If an entry already exists for +/// the same task (rare; would require the same task applying the +/// same id twice in one delta), the existing entry's analysis is +/// replaced with the incoming — the latest write wins. +fn record_detail(finding: &mut Finding, task_id: Option<&str>, task_analysis: Option<&str>) { + let (Some(tid), Some(body)) = (task_id, task_analysis) else { + return; + }; + if tid.is_empty() || body.trim().is_empty() { + return; + } + if let Some(existing) = finding.details.iter_mut().find(|d| d.task == tid) { + existing.analysis = body.to_string(); + return; + } + finding.details.push(FindingDetail { + task: tid.to_string(), + analysis: body.to_string(), + }); +} + +/// Merge `incoming` into `existing` in place. Returns true iff any +/// field on `existing` changed. +/// +/// Prose-field policy: to protect against a later task that mentions +/// the same finding id in passing overwriting a richer earlier body, +/// we only take the incoming value when it's at least as long as the +/// existing one. Ties keep `existing` (idempotent). This is a blunt +/// heuristic — a slow agent that rewrites a summary to be more +/// precise but SHORTER loses — but it prevents the common downgrade +/// path (incoming is a one-sentence reminder; existing is the full +/// analysis). Empty incoming is always ignored regardless of length. +fn merge_into(existing: &mut Finding, incoming: &Finding, task_id: Option<&str>) -> bool { + let mut changed = false; + + // Status transitions. `reactivate: true` is a specific, rarer + // signal; when set it WINS regardless of what `status` carries. + // Treating reactivate as authoritative avoids a contradictory + // delta (both `status: "invalidated"` and `reactivate: true`) + // producing a transient Active→Invalidated→Active flip with + // `changed` set twice for what is really a single transition. + if incoming.reactivate { + if existing.status == Status::Invalidated { + existing.status = Status::Active; + changed = true; } - .await - { - drop(f); - roll_back_counters(self, &[&tmp]); - return Err(FindingsError::Io(e)); + } else if incoming.status == Status::Invalidated + && existing.status != Status::Invalidated + { + existing.status = Status::Invalidated; + changed = true; + } + + // Prefer the higher severity. + if incoming.severity > existing.severity { + existing.severity = incoming.severity; + changed = true; + } + + // Prose fields: longer-wins, guarded against downgrades. + changed |= prefer_longer(&mut existing.title, &incoming.title); + changed |= prefer_longer(&mut existing.summary, &incoming.summary); + changed |= prefer_longer(&mut existing.reproducer_sketch, &incoming.reproducer_sketch); + changed |= prefer_longer(&mut existing.impact, &incoming.impact); + changed |= prefer_longer_opt(&mut existing.mechanism_detail, &incoming.mechanism_detail); + changed |= prefer_longer_opt(&mut existing.fix_sketch, &incoming.fix_sketch); + + // Union collections. + changed |= union_symbols(&mut existing.relevant_symbols, &incoming.relevant_symbols); + changed |= union_sections( + &mut existing.relevant_file_sections, + &incoming.relevant_file_sections, + ); + changed |= union_strings(&mut existing.open_questions, &incoming.open_questions); + changed |= union_strings( + &mut existing.related_finding_ids, + &incoming.related_finding_ids, + ); + + if let Some(t) = task_id { + let stamp = Some(t.to_string()); + if existing.last_updated_task != stamp { + existing.last_updated_task = stamp; + changed = true; } - drop(f); - if let Err(e) = tokio::fs::rename(&tmp, &canonical).await { - roll_back_counters(self, &[&tmp]); - return Err(FindingsError::Io(e)); + if existing.first_seen_task.is_none() { + existing.first_seen_task = Some(t.to_string()); + changed = true; } - if let Ok(dir_file) = tokio::fs::File::open(&self.parent_dir).await { - if let Err(e) = dir_file.sync_all().await { - tracing::warn!( - target: "kres_core", - dir = %self.parent_dir.display(), - "findings parent-dir fsync failed: {e}" - ); - } + } + + changed +} + +/// Overwrite `existing` with `incoming` when the incoming value is +/// strictly longer, OR when `existing` is empty and `incoming` is +/// not. Ties and downgrades keep `existing`. Returns true iff +/// `existing` changed. +fn prefer_longer(existing: &mut String, incoming: &str) -> bool { + if incoming.is_empty() || incoming == existing { + return false; + } + if existing.is_empty() || incoming.len() > existing.len() { + *existing = incoming.to_string(); + return true; + } + false +} + +fn prefer_longer_opt(existing: &mut Option, incoming: &Option) -> bool { + let Some(inc) = incoming else { return false }; + if inc.is_empty() { + return false; + } + match existing { + Some(cur) if cur == inc => false, + Some(cur) if inc.len() > cur.len() => { + *existing = Some(inc.clone()); + true } + Some(_) => false, + None => { + *existing = Some(inc.clone()); + true + } + } +} - // The canonical `findings.json` is the authoritative latest - // state on every turn, so WrittenTurn.path always points - // there. Operators who want turn-N's prior state can read - // `findings-N.json` directly; it's just `path_for(n)`. - Ok(WrittenTurn { - path: canonical, - turn_n: n, - tasks_since_change, - }) +/// Return the subset of `store` whose identifying tokens appear in +/// `prose`. "Identifying tokens" means any of: +/// - the Finding's `id`, +/// - the basename or full path of any `relevant_symbols[].filename` +/// or `relevant_file_sections[].filename`, +/// - the `name` of any `relevant_symbols[]` entry (matched as a +/// whole-word identifier). +/// +/// Used to narrow the promoter's prompt payload: the audit LLM only +/// needs to see findings that could plausibly match what the prose +/// describes, not the whole store. False negatives (a relevant +/// finding missed by the scan) are handled by the caller's dedup +/// filter, which sees the full store and renames colliding ids — +/// never drops. +/// +/// The scan is intentionally generous: when in doubt, include. +pub fn relevant_subset(prose: &str, store: &[Finding]) -> Vec { + if prose.is_empty() || store.is_empty() { + return Vec::new(); } + store + .iter() + .filter(|f| finding_mentioned_in_prose(f, prose)) + .cloned() + .collect() +} - /// Number of consecutive turns without a change. - pub fn tasks_since_change(&self) -> u32 { - self.state.lock().unwrap().tasks_since_change +fn finding_mentioned_in_prose(f: &Finding, prose: &str) -> bool { + // Match the id with identifier boundaries so a short id like + // "y" doesn't match inside "Only" or similar. + if !f.id.is_empty() && identifier_in_prose(&f.id, prose) { + return true; + } + for sym in &f.relevant_symbols { + if !sym.filename.is_empty() && file_in_prose(&sym.filename, prose) { + return true; + } + if !sym.name.is_empty() && identifier_in_prose(&sym.name, prose) { + return true; + } } + for sec in &f.relevant_file_sections { + if !sec.filename.is_empty() && file_in_prose(&sec.filename, prose) { + return true; + } + } + false +} - pub fn last_turn(&self) -> u32 { - self.state.lock().unwrap().last_turn +/// True iff `path` (or its basename) appears as a substring of +/// `prose`. Substring match is OK here because filenames include +/// slashes and dots that rarely collide with unrelated prose tokens. +fn file_in_prose(path: &str, prose: &str) -> bool { + if prose.contains(path) { + return true; } + if let Some(base) = path.rsplit('/').next() { + if !base.is_empty() && base != path && prose.contains(base) { + return true; + } + } + false } -#[derive(Debug, Clone)] -pub struct InitialState { - pub path: Option, - pub turn_n: u32, - pub findings: Vec, - pub tasks_since_change: u32, +/// True iff `ident` appears in `prose` bounded on both sides by a +/// non-identifier char (or start/end of string). Prevents +/// "free" matching inside "freed" or "cpu_mask" inside +/// "cpu_mask_var". Only ASCII alphanumerics and `_` count as +/// identifier chars; everything else (punctuation, whitespace, +/// UTF-8 letters) is a boundary. +fn identifier_in_prose(ident: &str, prose: &str) -> bool { + if ident.is_empty() || ident.len() > prose.len() { + return false; + } + let p = prose.as_bytes(); + let n = ident.as_bytes(); + let mut i = 0usize; + while let Some(hit) = find_from(p, n, i) { + let before_ok = hit == 0 || !is_ident_byte(p[hit - 1]); + let after_ok = hit + n.len() == p.len() || !is_ident_byte(p[hit + n.len()]); + if before_ok && after_ok { + return true; + } + i = hit + 1; + } + false } -#[derive(Debug, Clone)] -pub struct WrittenTurn { - pub path: PathBuf, - pub turn_n: u32, - pub tasks_since_change: u32, +fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() || needle.is_empty() || hay.len() < needle.len() { + return None; + } + hay[from..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|off| off + from) +} + +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +fn union_symbols(dst: &mut Vec, src: &[RelevantSymbol]) -> bool { + let mut changed = false; + for s in src { + let dup = dst + .iter() + .any(|e| e.filename == s.filename && e.line == s.line && e.name == s.name); + if !dup { + dst.push(s.clone()); + changed = true; + } + } + changed +} + +fn union_sections(dst: &mut Vec, src: &[RelevantFileSection]) -> bool { + let mut changed = false; + for s in src { + let dup = dst + .iter() + .any(|e| e.filename == s.filename && e.line_start == s.line_start); + if !dup { + dst.push(s.clone()); + changed = true; + } + } + changed +} + +fn union_strings(dst: &mut Vec, src: &[String]) -> bool { + let mut changed = false; + for s in src { + if !dst.iter().any(|e| e == s) { + dst.push(s.clone()); + changed = true; + } + } + changed } #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; fn tmp_dir(nonce: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!( - "kres-findings-test-{}-{}", + "kres-findings-test-{}-{}-{:x}", nonce, - std::process::id() + std::process::id(), + rand_suffix() )); std::fs::create_dir_all(&p).unwrap(); p } + fn rand_suffix() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + } + fn sample_finding(id: &str) -> Finding { Finding { id: id.to_string(), @@ -487,195 +679,435 @@ mod tests { first_seen_task: None, last_updated_task: None, related_finding_ids: vec![], + reactivate: false, + details: vec![], } } #[tokio::test] - async fn write_turn_writes_canonical_first_time() { - // First write has no prior canonical to snapshot, so the - // result lives in findings.json and no findings-1.json gets - // created yet. - let dir = tmp_dir("create"); + async fn details_record_one_entry_per_task_and_redact_clears() { + // apply_delta with a non-empty task_analysis stamps a + // FindingDetail on every finding it adds or updates. A + // second apply under a DIFFERENT task_id appends; under + // the SAME task_id overwrites. redacted_for_agent must + // then strip every entry. + let dir = tmp_dir("details"); let base = dir.join("findings.json"); - let store = FindingsStore::new(&base).unwrap(); - let wt = store - .write_turn(vec![sample_finding("a")], true) + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a")], Some("t1"), Some("first pass prose")) + .await + .unwrap(); + store + .apply_delta( + &[sample_finding("a")], + Some("t2"), + Some("second pass prose extends"), + ) .await .unwrap(); - assert_eq!(wt.turn_n, 1); - assert!(base.exists(), "canonical findings.json should exist"); + // Same task_id, different prose → overwrite, not append. + store + .apply_delta( + &[sample_finding("a")], + Some("t2"), + Some("second pass prose v2"), + ) + .await + .unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].details.len(), 2, "two distinct tasks"); + assert_eq!(snap[0].details[0].task, "t1"); + assert_eq!(snap[0].details[0].analysis, "first pass prose"); + assert_eq!(snap[0].details[1].task, "t2"); + assert_eq!( + snap[0].details[1].analysis, "second pass prose v2", + "same task_id overwrites" + ); + let redacted = redact_findings_for_agent(&snap); assert!( - !dir.join("findings-1.json").exists(), - "no snapshot on first write (nothing to snapshot)" + redacted[0].details.is_empty(), + "redacted copy must clear details" ); - let raw = std::fs::read_to_string(&base).unwrap(); - let parsed: FindingsFile = serde_json::from_str(&raw).unwrap(); - assert_eq!(parsed.findings.len(), 1); - assert_eq!(parsed.turn_n, Some(1)); + // Empty analysis must NOT record a detail entry. + store + .apply_delta(&[sample_finding("a")], Some("t3"), Some("")) + .await + .unwrap(); + let snap2 = store.snapshot().await; + assert_eq!(snap2[0].details.len(), 2, "empty analysis skipped"); + // Incoming delta carrying its own details on a NEW id must + // not persist them — only apply_delta's task_analysis arg + // populates the field. + let mut tainted = sample_finding("b"); + tainted.details.push(FindingDetail { + task: "forged".into(), + analysis: "leaked".into(), + }); + store + .apply_delta(&[tainted], Some("t4"), Some("legit")) + .await + .unwrap(); + let b = store + .snapshot() + .await + .into_iter() + .find(|f| f.id == "b") + .unwrap(); + assert_eq!(b.details.len(), 1); + assert_eq!(b.details[0].task, "t4"); + assert_eq!(b.details[0].analysis, "legit"); std::fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn write_turn_snapshots_prior_canonical() { - // Second write should copy the pre-existing findings.json to - // findings-2.json, then overwrite findings.json with the new - // content. - let dir = tmp_dir("snapshot"); + async fn first_apply_writes_canonical_file() { + let dir = tmp_dir("create"); let base = dir.join("findings.json"); - let store = FindingsStore::new(&base).unwrap(); - store - .write_turn(vec![sample_finding("a")], true) + let store = FindingsStore::new(&base).await.unwrap(); + let rep = store + .apply_delta(&[sample_finding("a")], Some("t1"), None) .await .unwrap(); - let wt = store - .write_turn(vec![sample_finding("a"), sample_finding("b")], true) + assert_eq!(rep.added, 1); + assert_eq!(rep.updated, 0); + assert!(rep.changed); + assert_eq!(rep.turn_n, 1); + assert!(base.exists()); + // Also verify jsondb stamped a `version` field on disk. + let raw = std::fs::read_to_string(&base).unwrap(); + assert!(raw.contains("\"version\"")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn matching_id_updates_in_place_and_unions_symbols() { + let dir = tmp_dir("merge"); + let base = dir.join("findings.json"); + let store = FindingsStore::new(&base).await.unwrap(); + let mut a = sample_finding("a"); + a.relevant_symbols.push(RelevantSymbol { + name: "foo".into(), + filename: "a.c".into(), + line: 1, + definition: "x".into(), + }); + store.apply_delta(&[a], Some("t1"), None).await.unwrap(); + + let mut b = sample_finding("a"); + b.summary = "fresh summary".into(); + b.relevant_symbols.push(RelevantSymbol { + name: "bar".into(), + filename: "b.c".into(), + line: 2, + definition: "y".into(), + }); + let rep = store.apply_delta(&[b], Some("t2"), None).await.unwrap(); + assert_eq!(rep.added, 0); + assert_eq!(rep.updated, 1); + assert!(rep.changed); + let snap = store.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].summary, "fresh summary"); + assert_eq!(snap[0].relevant_symbols.len(), 2); + assert_eq!(snap[0].first_seen_task.as_deref(), Some("t1")); + assert_eq!(snap[0].last_updated_task.as_deref(), Some("t2")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn reactivate_flag_flips_invalidated_back_to_active() { + let dir = tmp_dir("reactivate"); + let base = dir.join("findings.json"); + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a")], Some("t1"), None) .await .unwrap(); - assert_eq!(wt.turn_n, 2); - let snap = dir.join("findings-2.json"); - assert!(snap.exists(), "snapshot of prior canonical"); - let prior: FindingsFile = - serde_json::from_str(&std::fs::read_to_string(&snap).unwrap()).unwrap(); - assert_eq!(prior.findings.len(), 1, "snapshot captured turn-1 state"); - let latest: FindingsFile = - serde_json::from_str(&std::fs::read_to_string(&base).unwrap()).unwrap(); - assert_eq!(latest.findings.len(), 2, "canonical has latest state"); - assert_eq!(latest.turn_n, Some(2)); + let mut inv = sample_finding("a"); + inv.status = Status::Invalidated; + let rep2 = store.apply_delta(&[inv], Some("t2"), None).await.unwrap(); + assert_eq!(rep2.invalidated, 1); + assert_eq!(rep2.reactivated, 0); + assert_eq!(store.snapshot().await[0].status, Status::Invalidated); + let mut reactive = sample_finding("a"); + reactive.status = Status::Active; + reactive.reactivate = true; + reactive.summary = "new evidence reverses it".into(); + let rep3 = store.apply_delta(&[reactive], Some("t3"), None).await.unwrap(); + // The reactivation must be counted as such — not folded into + // the generic "updated" bucket. + assert_eq!(rep3.reactivated, 1); + assert_eq!(rep3.invalidated, 0); + assert_eq!(rep3.updated, 0); + let snap = store.snapshot().await; + assert_eq!(snap[0].status, Status::Active); + // `reactivate` must not persist on the stored record. + assert!(!snap[0].reactivate); std::fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn discover_latest_finds_highest_n() { - let dir = tmp_dir("discover"); + async fn reactivate_wins_over_contradictory_invalidated_status() { + // A misbehaving incoming delta that carries BOTH + // `status: "invalidated"` AND `reactivate: true` must + // resolve to Active and must not flip twice internally. + // reactivate is the more specific signal and wins outright. + let dir = tmp_dir("reactivate-wins"); let base = dir.join("findings.json"); - // Drop files with mixed numbers + a noise file. - for n in [1, 2, 5, 3] { - std::fs::write( - dir.join(format!("findings-{n}.json")), - r#"{"findings":[],"tasks_since_change":0}"#, - ) + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a")], Some("t1"), None) + .await .unwrap(); - } - std::fs::write(dir.join("other-4.json"), "{}").unwrap(); - std::fs::write(dir.join("findings.json"), "{}").unwrap(); - let store = FindingsStore::new(&base).unwrap(); - let (path, n) = store.discover_latest().unwrap().unwrap(); - assert_eq!(n, 5); - assert!(path.ends_with("findings-5.json")); + let mut inv = sample_finding("a"); + inv.status = Status::Invalidated; + store.apply_delta(&[inv], Some("t2"), None).await.unwrap(); + assert_eq!(store.snapshot().await[0].status, Status::Invalidated); + let mut both = sample_finding("a"); + both.status = Status::Invalidated; + both.reactivate = true; + store.apply_delta(&[both], Some("t3"), None).await.unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap[0].status, Status::Active); + assert!(!snap[0].reactivate); std::fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn bootstrap_seeds_counters() { - let dir = tmp_dir("boot"); + async fn shorter_incoming_prose_does_not_overwrite_longer_existing() { + let dir = tmp_dir("downgrade"); let base = dir.join("findings.json"); - std::fs::write( - dir.join("findings-3.json"), - r#"{"findings":[],"tasks_since_change":4,"turn_n":3}"#, - ) - .unwrap(); - let store = FindingsStore::new(&base).unwrap(); - let init = store.bootstrap().unwrap(); - assert_eq!(init.turn_n, 3); - assert_eq!(init.tasks_since_change, 4); - assert_eq!(store.last_turn(), 3); - assert_eq!(store.tasks_since_change(), 4); - // Next write should be turn 4, not 1. - let wt = store.write_turn(vec![], false).await.unwrap(); - assert_eq!(wt.turn_n, 4); - // `changed=false` advances tasks_since_change. - assert_eq!(wt.tasks_since_change, 5); + let store = FindingsStore::new(&base).await.unwrap(); + let mut rich = sample_finding("a"); + rich.summary = "a detailed five-paragraph explanation with lots of context".into(); + rich.impact = "detailed impact statement with concrete code paths".into(); + rich.mechanism_detail = Some("rich mechanism context".into()); + rich.fix_sketch = Some("rich fix with file:line anchors".into()); + store.apply_delta(&[rich], Some("t1"), None).await.unwrap(); + let mut thin = sample_finding("a"); + thin.summary = "brief summary".into(); + thin.impact = "bad".into(); + thin.mechanism_detail = Some("terse".into()); + thin.fix_sketch = Some("patch".into()); + store.apply_delta(&[thin], Some("t2"), None).await.unwrap(); + let snap = store.snapshot().await; + assert!(snap[0].summary.starts_with("a detailed")); + assert!(snap[0].impact.starts_with("detailed")); + assert_eq!(snap[0].mechanism_detail.as_deref(), Some("rich mechanism context")); + assert_eq!(snap[0].fix_sketch.as_deref(), Some("rich fix with file:line anchors")); + // last_updated_task still advances even when prose didn't win. + assert_eq!(snap[0].last_updated_task.as_deref(), Some("t2")); std::fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn bootstrap_prefers_canonical_when_present() { - // findings.json reflects the latest post-merge state; when - // present it wins over the highest numbered findings-N.json. - let dir = tmp_dir("boot-canonical"); + async fn longer_incoming_prose_overwrites_existing() { + let dir = tmp_dir("upgrade"); let base = dir.join("findings.json"); - std::fs::write( - dir.join("findings-2.json"), - r#"{"findings":[],"tasks_since_change":2,"turn_n":2}"#, - ) - .unwrap(); - std::fs::write( - &base, - r#"{"findings":[{"id":"x","title":"x","severity":"high","status":"active","summary":"s","reproducer_sketch":"r","impact":"i"}],"tasks_since_change":0,"turn_n":2}"#, - ) - .unwrap(); - let store = FindingsStore::new(&base).unwrap(); - let init = store.bootstrap().unwrap(); - assert_eq!(init.findings.len(), 1); - assert_eq!(init.findings[0].id, "x"); - assert_eq!(init.turn_n, 2); - // Next write should snapshot current canonical to findings-3. - let wt = store.write_turn(vec![], false).await.unwrap(); - assert_eq!(wt.turn_n, 3); - assert!(dir.join("findings-3.json").exists()); + let store = FindingsStore::new(&base).await.unwrap(); + let mut thin = sample_finding("a"); + thin.summary = "short".into(); + store.apply_delta(&[thin], Some("t1"), None).await.unwrap(); + let mut rich = sample_finding("a"); + rich.summary = "much more detailed summary with concrete specifics".into(); + store.apply_delta(&[rich], Some("t2"), None).await.unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap[0].summary, "much more detailed summary with concrete specifics"); std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn prefer_longer_helpers_behaviour() { + let mut s = String::from("abcd"); + assert!(!prefer_longer(&mut s, "")); + assert!(!prefer_longer(&mut s, "abcd")); + assert!(!prefer_longer(&mut s, "xy")); // shorter stays + assert_eq!(s, "abcd"); + assert!(prefer_longer(&mut s, "abcdef")); + assert_eq!(s, "abcdef"); + + let mut o: Option = None; + assert!(prefer_longer_opt(&mut o, &Some("hello".into()))); + assert_eq!(o.as_deref(), Some("hello")); + assert!(!prefer_longer_opt(&mut o, &Some("hi".into()))); + assert_eq!(o.as_deref(), Some("hello")); + assert!(prefer_longer_opt(&mut o, &Some("hello world".into()))); + assert_eq!(o.as_deref(), Some("hello world")); + assert!(!prefer_longer_opt(&mut o, &None)); + assert!(!prefer_longer_opt(&mut o, &Some("".into()))); + } + #[tokio::test] - async fn tasks_since_change_resets_on_change() { - let dir = tmp_dir("reset"); + async fn invalidation_flips_status_without_losing_body() { + let dir = tmp_dir("invalidate"); let base = dir.join("findings.json"); - let store = FindingsStore::new(&base).unwrap(); - for _ in 0..3 { - store.write_turn(vec![], false).await.unwrap(); - } - assert_eq!(store.tasks_since_change(), 3); - let wt = store - .write_turn(vec![sample_finding("x")], true) + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a")], Some("t1"), None) .await .unwrap(); - assert_eq!(wt.tasks_since_change, 0); + let mut inv = sample_finding("a"); + inv.status = Status::Invalidated; + inv.summary = "".into(); // empty: don't overwrite + let rep = store.apply_delta(&[inv], Some("t2"), None).await.unwrap(); + assert_eq!(rep.invalidated + rep.updated, 1); + let snap = store.snapshot().await; + assert_eq!(snap[0].status, Status::Invalidated); + assert_eq!(snap[0].summary, "s"); std::fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn concurrent_writes_never_collide_on_n() { - // Smoke test for bugs.md#H2: two tasks racing to write should - // each get a unique, monotonically-increasing N. - let dir = tmp_dir("race"); + async fn severity_only_escalates() { + let dir = tmp_dir("severity"); let base = dir.join("findings.json"); - let store = Arc::new(FindingsStore::new(&base).unwrap()); - let mut handles = vec![]; - for _ in 0..8 { - let s = store.clone(); - handles.push(tokio::spawn(async move { - s.write_turn(vec![], false).await.unwrap() - })); - } - let mut ns: Vec = vec![]; - for h in handles { - ns.push(h.await.unwrap().turn_n); + let store = FindingsStore::new(&base).await.unwrap(); + let mut hi = sample_finding("a"); + hi.severity = Severity::Critical; + store.apply_delta(&[hi], Some("t1"), None).await.unwrap(); + let mut lo = sample_finding("a"); + lo.severity = Severity::Low; + store.apply_delta(&[lo], Some("t2"), None).await.unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap[0].severity, Severity::Critical); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn reload_preserves_findings() { + let dir = tmp_dir("reload"); + let base = dir.join("findings.json"); + { + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a"), sample_finding("b")], Some("t1"), None) + .await + .unwrap(); + store.db.flush().await; } - ns.sort(); - assert_eq!(ns, vec![1, 2, 3, 4, 5, 6, 7, 8]); + let store = FindingsStore::new(&base).await.unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap.len(), 2); + assert_eq!(store.last_turn().await, 1); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn tasks_since_change_resets_on_change() { + let dir = tmp_dir("tsc"); + let base = dir.join("findings.json"); + let store = FindingsStore::new(&base).await.unwrap(); + // Empty delta = no change. + let r0 = store.apply_delta(&[], Some("t0"), None).await.unwrap(); + assert!(!r0.changed); + assert_eq!(r0.tasks_since_change, 1); + let r1 = store.apply_delta(&[], Some("t1"), None).await.unwrap(); + assert_eq!(r1.tasks_since_change, 2); + let r2 = store + .apply_delta(&[sample_finding("a")], Some("t2"), None) + .await + .unwrap(); + assert!(r2.changed); + assert_eq!(r2.tasks_since_change, 0); std::fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn tmp_file_not_left_behind_on_success() { - // bugs.md#H6 path-hygiene check. - let dir = tmp_dir("tmp-clean"); + async fn legacy_unversioned_file_loads() { + // A pre-jsondb findings.json has no `version` field. Because + // FindingsFile: SchemaV0 with VERSION_OPTIONAL = true, jsondb + // is supposed to accept it as V0. + let dir = tmp_dir("legacy"); let base = dir.join("findings.json"); - let store = FindingsStore::new(&base).unwrap(); - // Two writes so we exercise both the canonical-write path and - // the snapshot-then-canonical path. - let _ = store.write_turn(vec![], true).await.unwrap(); - let wt = store.write_turn(vec![], true).await.unwrap(); - assert!(wt.path.exists()); - let stray: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) - .collect(); - assert!(stray.is_empty(), "unexpected tmp leftovers: {stray:?}"); + std::fs::write( + &base, + r#"{"findings":[{"id":"old","title":"t","severity":"high","summary":"s","reproducer_sketch":"r","impact":"i"}],"tasks_since_change":2,"turn_n":7}"#, + ) + .unwrap(); + let store = FindingsStore::new(&base).await.unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].id, "old"); + assert_eq!(store.tasks_since_change().await, 2); + assert_eq!(store.last_turn().await, 7); std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn relevant_subset_matches_on_id_mention() { + let f = sample_finding("race_in_cq_ack"); + let sub = relevant_subset("This reinforces finding race_in_cq_ack — see more.", &[f]); + assert_eq!(sub.len(), 1); + } + + #[test] + fn relevant_subset_matches_on_filename_basename() { + let mut f = sample_finding("x"); + f.relevant_symbols.push(RelevantSymbol { + name: "foo".into(), + filename: "drivers/net/ethernet/intel/ice/ice_main.c".into(), + line: 100, + definition: "".into(), + }); + let sub1 = relevant_subset("See ice_main.c:42 for details.", &[f.clone()]); + assert_eq!(sub1.len(), 1); + let sub2 = relevant_subset( + "See drivers/net/ethernet/intel/ice/ice_main.c:42.", + &[f.clone()], + ); + assert_eq!(sub2.len(), 1); + let sub3 = relevant_subset("Nothing relevant here.", &[f]); + assert!(sub3.is_empty()); + } + + #[test] + fn relevant_subset_matches_on_symbol_name_boundary() { + let mut f = sample_finding("x"); + f.relevant_symbols.push(RelevantSymbol { + name: "cpu_mask".into(), + filename: "lib/cpumask.c".into(), + line: 10, + definition: "".into(), + }); + // Whole-word match: `cpu_mask` in a sentence → hit. + let sub1 = relevant_subset("The cpu_mask buffer is freed.", &[f.clone()]); + assert_eq!(sub1.len(), 1); + // Embedded inside `cpu_mask_var` → NOT a hit via identifier + // match (identifier boundary enforced). + let mut g = sample_finding("y"); + g.relevant_symbols.push(RelevantSymbol { + name: "cpu_mask".into(), + filename: "lib/other.c".into(), + line: 20, + definition: "".into(), + }); + let sub2 = relevant_subset("Only cpu_mask_var mentioned.", &[g]); + assert!(sub2.is_empty()); + } + + #[test] + fn relevant_subset_includes_generously_on_any_signal() { + // A finding should be included if ANY of id / filename / + // symbol-name matches — not all of them. + let mut f = sample_finding("race_x"); + f.relevant_symbols.push(RelevantSymbol { + name: "completely_unrelated".into(), + filename: "a/b/c.c".into(), + line: 1, + definition: "".into(), + }); + let sub = relevant_subset("reinforces finding race_x — see details", &[f]); + assert_eq!(sub.len(), 1); + } + + #[test] + fn relevant_subset_empty_inputs() { + assert!(relevant_subset("", &[sample_finding("x")]).is_empty()); + assert!(relevant_subset("some prose", &[]).is_empty()); + } + #[test] fn optional_fields_serialise_only_when_present() { let mut f = sample_finding("x"); @@ -697,17 +1129,4 @@ mod tests { assert!(s.contains("\"severity\":\"high\"")); assert!(s.contains("\"status\":\"active\"")); } - - #[test] - fn preflight_rejects_unwritable_parent() { - // bugs.md#L4 — a FindingsStore pointing through /dev/null - // can't create its parent directory. Preflight must surface - // that at construction, not at first write. - let bad = PathBuf::from("/dev/null/nested/findings.json"); - match FindingsStore::new(&bad) { - Ok(_) => panic!("expected preflight failure"), - Err(FindingsError::Io(_)) => {} - Err(other) => panic!("wrong error kind: {other}"), - } - } } diff --git a/kres-core/src/lib.rs b/kres-core/src/lib.rs index f26d2fe..92e657d 100644 --- a/kres-core/src/lib.rs +++ b/kres-core/src/lib.rs @@ -6,11 +6,11 @@ //! - C2: every Task owns a CancellationToken; /stop / /clear / goal-met //! / --turns propagate cancel before dropping references. //! - C3: abandoning a Task waits for its handle, never strands it. -//! - H1: the merge critical section is split — the extract lock holds -//! only during disk write, not across API calls. -//! - H2/H3: findings_write_count is incremented under the same mutex -//! that allocates N and writes the file — one atomic unit. -//! - H6: findings-N.json is written via tmp-file + fsync + rename. +//! - H1: no LLM call runs inside the findings-extract critical +//! section; `FindingsStore::apply_delta` does a pure Rust merge +//! and the jsondb-owned RwLock serialises disk writes. +//! - H6: the canonical findings.json is written via jsondb's +//! tmp-file + fsync + rename pipeline (no history snapshots). //! - L1: no parallel "completed_ids" vector — done tasks are queried //! directly off the ordered task list. @@ -30,7 +30,10 @@ pub mod todo; pub use consent::ConsentStore; pub use cost::{UsageEntry, UsageKey, UsageTracker}; -pub use findings::{Finding, FindingsFile, FindingsStore, Severity}; +pub use findings::{ + apply_delta_to_list, redact_findings_for_agent, relevant_subset, ApplyReport, DeltaCounts, + Finding, FindingDetail, FindingsFile, FindingsStore, Severity, Status, +}; pub use lens::LensSpec; pub use log::{LoggedUsage, TurnLogger}; pub use mode::{CodeEdit, CodeFile, TaskMode}; diff --git a/kres-core/src/shrink.rs b/kres-core/src/shrink.rs index b3c36e8..b64bd80 100644 --- a/kres-core/src/shrink.rs +++ b/kres-core/src/shrink.rs @@ -237,6 +237,8 @@ mod tests { first_seen_task: None, last_updated_task: None, related_finding_ids: vec![], + reactivate: false, + details: vec![], } } diff --git a/kres-core/src/task.rs b/kres-core/src/task.rs index cbf653f..0cacd76 100644 --- a/kres-core/src/task.rs +++ b/kres-core/src/task.rs @@ -26,6 +26,7 @@ use std::time::Duration; use tokio::sync::{Mutex, Notify, RwLock}; use tokio::task::JoinHandle; +use uuid::Uuid; use crate::findings::Finding; use crate::shutdown::Shutdown; @@ -52,7 +53,17 @@ impl TaskState { pub struct Task { pub id: TaskId, + /// Random v4 uuid generated at spawn time. Distinct from `id` + /// (a process-local monotonic u64): the uuid is stable across + /// restarts only in the sense of never colliding, so it's safe + /// to stamp into `findings.json` for provenance across sessions. + pub uuid: Uuid, pub name: String, + /// Short tag for the todo that dispatched this task (TodoItem.id + /// when non-empty, otherwise TodoItem.name). None for operator- + /// typed prompts that don't come from the todo list. Fed into + /// `FindingsStore::apply_delta` as part of the stamp so a + /// finding's provenance records which todo produced it. pub todo_name: Option, pub shutdown: Shutdown, /// State is behind a single RwLock on the manager; a Task itself @@ -228,6 +239,7 @@ impl TaskManager { let shutdown = self.root_shutdown.child(); let task = Task { id, + uuid: Uuid::new_v4(), name: name.into(), todo_name, shutdown: shutdown.clone(), @@ -286,6 +298,7 @@ impl TaskManager { g.tasks.push(TaskEntry { task: Task { id, + uuid: task.uuid, name: task.name.clone(), todo_name: task.todo_name.clone(), shutdown: shutdown.clone(), @@ -442,6 +455,7 @@ impl TaskManager { if entry.state.is_terminal() { reaped.push(ReapedTask { id: entry.task.id, + uuid: entry.task.uuid, name: entry.task.name, todo_name: entry.task.todo_name, state: entry.state, @@ -468,6 +482,7 @@ impl TaskManager { .iter() .map(|e| TaskSnapshot { id: e.task.id, + uuid: e.task.uuid, name: e.task.name.clone(), state: e.state, todo_name: e.task.todo_name.clone(), @@ -652,6 +667,7 @@ pub struct StopAllOutcome { #[derive(Debug)] pub struct TaskSnapshot { pub id: TaskId, + pub uuid: Uuid, pub name: String, pub state: TaskState, pub todo_name: Option, @@ -660,6 +676,7 @@ pub struct TaskSnapshot { #[derive(Debug)] pub struct ReapedTask { pub id: TaskId, + pub uuid: Uuid, pub name: String, pub todo_name: Option, pub state: TaskState, diff --git a/kres-repl/src/report.rs b/kres-repl/src/report.rs index 545b079..9cb41f7 100644 --- a/kres-repl/src/report.rs +++ b/kres-repl/src/report.rs @@ -176,6 +176,8 @@ mod tests { first_seen_task: None, last_updated_task: None, related_finding_ids: vec!["other".into()], + reactivate: false, + details: vec![], } } diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 43686c4..c9a1111 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -17,8 +17,8 @@ use crate::commands::{parse_command, Command}; #[derive(Debug, Clone)] pub struct ReplConfig { pub stop_grace: Duration, - /// Path to `findings.json` base (per-turn files written as - /// `findings-N.json`). When None, nothing is written to disk. + /// Path to the canonical `findings.json` (jsondb-backed). When + /// None, nothing is written to disk and findings stay in memory. pub findings_base: Option, /// Stop the REPL after N completed task runs (0 = unlimited). /// Matches semantics. @@ -240,6 +240,16 @@ pub struct Session { /// was still sitting in the todo list — which is NOT what an /// operator who just hit Ctrl-C's moral equivalent wants. stop_latched: Arc, + /// Woken by `cmd_stop` alongside the `stop_latched` atomic so + /// an in-flight reaper-side inference call (the promoter today; + /// the consolidator / todo-agent / merger in principle) can + /// `tokio::select!` on `notified()` and abandon its API + /// round-trip instead of running to completion while the + /// operator waits for /stop to take effect. Notify is edge- + /// triggered — notifications with no waiter are discarded, + /// which matches the reaper's behaviour: the latched atomic + /// catches the next iteration when no call is mid-flight. + stop_notify: Arc, /// Pauses the 200ms status-row repainter while a child process /// (vim launched by /edit, for instance) has the terminal. /// Without this, the repainter absolute-positions to row H-1 @@ -348,11 +358,11 @@ pub struct AccumulatedEntry { } impl Session { - pub fn new(mgr: Arc, cfg: ReplConfig) -> Self { - // Eagerly create the parent of the findings base so - // FindingsStore::new can preflight its probe without the - // user having to `mkdir -p` themselves. Matches what - // did implicitly by the --results DIR convention. + pub async fn new(mgr: Arc, cfg: ReplConfig) -> Self { + // Eagerly create the parent of the findings base so the + // jsondb-backed store can open without the user having to + // `mkdir -p` themselves. Matches what the pre-jsondb store + // did implicitly. if let Some(ref p) = cfg.findings_base { if let Some(parent) = p.parent() { if let Err(e) = std::fs::create_dir_all(parent) { @@ -363,78 +373,65 @@ impl Session { } } } - let findings_store = - cfg.findings_base - .as_ref() - .and_then(|p| match FindingsStore::new(p.clone()) { - Ok(fs) => Some(Arc::new(fs)), - Err(e) => { - kres_core::async_eprintln!( - "findings: store init failed for {}: {e}", - p.display() - ); - None - } - }); - if let Some(ref fs) = findings_store { - match fs.bootstrap() { - Ok(init) => { - let turn_n = init.turn_n; - let count = init.findings.len(); - let findings = init.findings; - // Seed the manager synchronously via - // blocking_lock-free futures::executor: the - // ergonomic fix is to hand the findings to - // `run()` which will replace them on the - // first reap tick, BEFORE submit_prompt can - // observe a stale snapshot. To preserve the - // previous behaviour without introducing a - // handle-back API, we store the bootstrap in - // the Session itself. - // - // See `Self::pending_bootstrap` below, consumed - // at the top of `run()`. + let mut findings_store: Option> = None; + if let Some(ref p) = cfg.findings_base { + match FindingsStore::new(p.clone()).await { + Ok(fs) => findings_store = Some(Arc::new(fs)), + Err(e) => { kres_core::async_eprintln!( - "findings: initialised at turn {} ({} existing)", - turn_n, - count + "findings: store init failed for {}: {e}", + p.display() ); - return Self { - mgr, - cfg, - orchestrator: None, - consolidator: None, - todo_client: None, - goal_client: None, - findings_store, - usage: Arc::new(UsageTracker::new()), - lenses: Vec::new(), - initial_prompt: None, - last_analysis: Arc::new(tokio::sync::Mutex::new(None)), - pending_bootstrap: findings, - logger: None, - task_goals: Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), - task_prompts: Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), - accumulated: Arc::new(tokio::sync::Mutex::new(Vec::new())), - deferred: Arc::new(tokio::sync::Mutex::new(Vec::new())), - interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), - last_prompt: Arc::new(tokio::sync::Mutex::new(None)), - persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), - turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), - any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), - stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), - status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), - input_ack_tx: tokio::sync::Mutex::new(None), - mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), - }; } - Err(e) => kres_core::async_eprintln!("findings bootstrap: {e}"), } } + if let Some(ref fs) = findings_store { + let turn_n = fs.last_turn().await; + let findings = fs.snapshot().await; + let count = findings.len(); + // Seed the manager via `pending_bootstrap`, consumed at + // the top of `run()`. This preserves the prior behaviour + // where the first reap tick establishes the in-memory + // list BEFORE submit_prompt observes a stale snapshot. + kres_core::async_eprintln!( + "findings: initialised at turn {} ({} existing)", + turn_n, + count + ); + return Self { + mgr, + cfg, + orchestrator: None, + consolidator: None, + todo_client: None, + goal_client: None, + findings_store, + usage: Arc::new(UsageTracker::new()), + lenses: Vec::new(), + initial_prompt: None, + last_analysis: Arc::new(tokio::sync::Mutex::new(None)), + pending_bootstrap: findings, + logger: None, + task_goals: Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), + task_prompts: Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), + accumulated: Arc::new(tokio::sync::Mutex::new(Vec::new())), + deferred: Arc::new(tokio::sync::Mutex::new(Vec::new())), + interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), + last_prompt: Arc::new(tokio::sync::Mutex::new(None)), + persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), + turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), + any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), + stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), + stop_notify: Arc::new(tokio::sync::Notify::new()), + status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), + input_ack_tx: tokio::sync::Mutex::new(None), + mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), + }; + } Self { mgr, cfg, @@ -459,6 +456,7 @@ impl Session { turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), + stop_notify: Arc::new(tokio::sync::Notify::new()), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), input_ack_tx: tokio::sync::Mutex::new(None), mcp_shutdown: Arc::new(tokio::sync::Mutex::new(Vec::new())), @@ -777,8 +775,8 @@ impl Session { let persist_path_for_reaper = self.cfg.persist_path.clone(); let last_prompt_for_reaper = self.last_prompt.clone(); let persist_sig_for_reaper = self.persist_sig.clone(); - let merger_for_reaper = self.consolidator.clone(); let store_for_reaper = self.findings_store.clone(); + let promoter_for_reaper = self.consolidator.clone(); let interrupted_for_reaper = self.interrupted_prompt.clone(); let report_path_for_reaper = self.cfg.report_path.clone(); // Destination for coding-mode file output. Coding tasks emit @@ -790,6 +788,7 @@ impl Session { let code_output_root_for_reaper: PathBuf = self.cfg.workspace.clone(); let turns_exhausted_for_reaper = self.turns_exhausted.clone(); let stop_latched_for_reaper = self.stop_latched.clone(); + let stop_notify_for_reaper = self.stop_notify.clone(); let turns_limit = self.cfg.turns_limit; let follow_followups = self.cfg.follow_followups; // §16: findings-signature watchdog. Every successful merge @@ -953,95 +952,238 @@ impl Session { if stop_latched_now { continue; } - // Findings merger runs for both Analysis (review) - // and Generic tasks — both feed the findings - // pipeline. Coding tasks skip it: their output is - // source files, not findings. - let had_delta = r.mode.produces_findings() && !r.findings_delta.is_empty(); + // Findings delta application runs for Analysis + // (review) and Generic tasks — both feed the + // findings pipeline. Coding tasks skip it: their + // output is source files, not findings. + // + // The LLM-based merger has been retired. The slow + // agent's prompt already tells it to reuse an + // existing finding's id when extending it; the + // store applies deterministic merge rules in Rust + // (kres_core::findings::apply_delta_to_list) — no + // token round-trip per turn. + // + // Promotion pass: the slow agent + consolidator + // PROMOTION RULE is instructional only. When they + // describe a bug in prose but don't emit the + // matching Finding (or when the response + // RawText-downgrades and the findings array is + // empty), the bug reaches report.md and is lost + // to findings.json. A one-shot fast-agent audit + // pass here reads effective_analysis against the + // current findings_delta and returns any net-new + // bugs it spots; we append those to the delta + // before apply_delta runs. Non-fatal: on any + // failure we skip and carry on with whatever the + // slow agent did emit. + let mut working_delta = r.findings_delta.clone(); + // Ids the promoter contributed on top of + // r.findings_delta. Populated when the audit + // pass returns extras; consumed below to append + // a cross-reference trailer to report.md so a + // human reader of the narrative can find the + // new Findings by id. + let mut promoted_ids: Vec = Vec::new(); + if r.mode.produces_findings() && !effective_analysis.is_empty() { + if let Some(ref promoter) = promoter_for_reaper { + // Assemble the full universe of known + // ids (store snapshot ∪ this task's + // delta). `apply_delta_to_list` matches + // by id against the store, so we need + // the whole universe for the Rust-side + // dedup filter to catch collisions. + let mut all_known = mgr_for_reaper.findings_snapshot().await; + for d in &working_delta { + if !all_known.iter().any(|e| e.id == d.id) { + all_known.push(d.clone()); + } + } + // Narrow the LLM-bound subset to findings + // actually mentioned (by id, filename, or + // symbol name) in the prose. A 500-entry + // store with full source bodies blows up + // the prompt; a typical prose chunk only + // touches a handful of those. False + // negatives in this scan are handled + // downstream: filter_net_new sees the + // full `all_known` and RENAMES colliding + // ids rather than dropping them. + let prose_relevant = kres_core::relevant_subset( + &effective_analysis, + &all_known, + ); + // Both slices go to the promoter's + // prompt path — redact Finding.details + // so the per-task narrative captured for + // /summary never round-trips through + // another LLM call. dedup_against only + // touches ids, but redact uniformly for + // discipline. + let prose_relevant = + kres_core::redact_findings_for_agent(&prose_relevant); + let all_known_for_dedup = + kres_core::redact_findings_for_agent(&all_known); + kres_core::async_eprintln!( + "[promote] sending {} of {} existing finding(s) to auditor", + prose_relevant.len(), + all_known.len(), + ); + match kres_agents::promote::promote_prose_bugs_with_logger( + promoter.client.clone(), + promoter.model.clone(), + // Use the dedicated promoter system + // prompt, NOT the consolidator's + // inherited fast-code-agent system. + // Same drift-avoidance reason the + // retired merger_system.txt existed. + Some(kres_agents::promote::PROMOTE_SYSTEM), + promoter.max_tokens, + promoter.max_input_tokens, + &r.name, + &effective_analysis, + &prose_relevant, + &all_known_for_dedup, + Some(stop_notify_for_reaper.clone()), + logger_for_reaper.clone(), + ) + .await + { + Ok(extras) if !extras.is_empty() => { + kres_core::async_eprintln!( + "[promote] {} prose-only bug(s) promoted to findings", + extras.len() + ); + promoted_ids + .extend(extras.iter().map(|f| f.id.clone())); + working_delta.extend(extras); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + target: "kres_repl", + "promote pass failed: {e}" + ); + } + } + } + } + let had_delta = r.mode.produces_findings() && !working_delta.is_empty(); + let mut apply_changed = false; + let mut apply_added: u32 = 0; + let mut apply_updated: u32 = 0; + let mut apply_invalidated: u32 = 0; + let mut apply_reactivated: u32 = 0; if had_delta { - // §16: when a consolidator client is available - // we reuse it as the findings merger too. - // `merge_findings_with_logger` takes a snapshot - // of the current list + the task's delta and - // asks the fast agent for a merged output. The - // with_findings_extract_lock() call serialises - // every merge — 's - // does the same to stop concurrent merges from - // racing. - if let Some(ref merger) = merger_for_reaper { - let current = mgr_for_reaper.findings_snapshot().await; - let delta = r.findings_delta.clone(); - let brief = r.name.clone(); - let merger_c = merger.clone(); - let logger_c = logger_for_reaper.clone(); - let merged = mgr_for_reaper + let delta = working_delta.clone(); + // Provenance stamp written into findings' + // first_seen_task / last_updated_task. Shape: + // "/" when a todo + // dispatched this task (cmd_next / + // cmd_continue paths), else just the uuid. + // We avoid the prior `r.name` convention — + // for an operator-typed `/review …` task + // `r.name` is the full prompt body, which + // got duplicated across every finding. + let stamp = match r.todo_name.as_deref() { + Some(tag) => format!("{}/{}", r.uuid.as_simple(), tag), + None => r.uuid.as_simple().to_string(), + }; + // effective_analysis is the prose we want on + // every finding this task touched, stored + // under `details` for /summary to consume + // later. Feed it to apply_delta alongside + // the stamp so the record_detail pass can + // attach one entry per finding per task. + let prose_for_details = effective_analysis.clone(); + if let Some(ref s) = store_for_reaper { + let s_c = s.clone(); + let stamp_c = stamp.clone(); + let prose_c = prose_for_details.clone(); + let report = mgr_for_reaper .with_findings_extract_lock(|| async move { - kres_agents::merge::merge_findings_with_logger( - merger_c.client.clone(), - merger_c.model.clone(), - // Use the dedicated merger - // system prompt so the model - // doesn't drift into - // fast-code-agent mode - // (returning {"goal":…} or - // tags) when the - // embedded MERGER_INSTRUCTIONS - // in the user message gets - // under-weighted. - Some(kres_agents::MERGER_SYSTEM), - merger_c.max_tokens, - merger_c.max_input_tokens, - &brief, - &delta, - ¤t, - logger_c, - ) - .await + s_c.apply_delta(&delta, Some(&stamp_c), Some(&prose_c)).await }) .await; - match merged { - Ok(new_list) => { - mgr_for_reaper.replace_findings(new_list).await; + match report { + Ok(rep) => { + apply_changed = rep.changed; + apply_added = rep.added; + apply_updated = rep.updated; + apply_invalidated = rep.invalidated; + apply_reactivated = rep.reactivated; + mgr_for_reaper.replace_findings(rep.merged).await; } Err(e) => { - tracing::warn!( - target: "kres_repl", - "merge_findings failed: {e}; applying naive union" - ); - let mut all = mgr_for_reaper.findings_snapshot().await; - let existing: std::collections::BTreeSet = - all.iter().map(|f| f.id.clone()).collect(); - for f in r.findings_delta { - if !existing.contains(&f.id) { - all.push(f); - } - } - mgr_for_reaper.replace_findings(all).await; + kres_core::async_eprintln!("findings apply: {e}"); } } } else { - let mut all = mgr_for_reaper.findings_snapshot().await; - let existing: std::collections::BTreeSet = - all.iter().map(|f| f.id.clone()).collect(); - for f in r.findings_delta { - if !existing.contains(&f.id) { - all.push(f); - } + // No persistent store (no --results set): + // apply the same rules to the in-memory + // list so the pipeline still benefits + // from deterministic dedup. + let mut current = mgr_for_reaper.findings_snapshot().await; + let counts = kres_core::apply_delta_to_list( + &mut current, + &delta, + Some(&stamp), + Some(&prose_for_details), + ); + apply_changed = counts.changed; + apply_added = counts.added; + apply_updated = counts.updated; + apply_invalidated = counts.invalidated; + apply_reactivated = counts.reactivated; + mgr_for_reaper.replace_findings(current).await; + } + } + // Promoted-findings cross-reference trailer on + // report.md. `effective_analysis` was appended + // earlier (before the /stop latch + promoter + + // apply_delta) — that ordering is load-bearing + // for the /stop-latched case, which otherwise + // would lose its prose. Appending a SECOND small + // section here now that we know which ids the + // promoter added lets a human reader of the + // narrative jump to the new Findings without + // re-reading the whole JSON store. Only append + // when apply_delta actually landed those ids — + // an apply_delta error above leaves promoted_ids + // unrecorded in findings.json, so a stray + // cross-reference would be misleading. + if !promoted_ids.is_empty() && apply_changed { + if let Some(ref rp) = report_path_for_reaper { + let joined = promoted_ids + .iter() + .map(|id| format!("`{id}`")) + .collect::>() + .join(", "); + let trailer = format!( + "_promoted-from-prose: {}_ ({})", + joined, + promoted_ids.len() + ); + if let Err(e) = + crate::report::append_task_section(rp, &r.name, &trailer) + { + tracing::warn!( + target: "kres_repl", + "report promoted-ids trailer append to {}: {e}", + rp.display() + ); } - mgr_for_reaper.replace_findings(all).await; } } - // Persist the CUMULATIVE findings list to disk - // once per reaped task. findings-N.json is the - // complete list of findings still considered - // relevant after this task's delta has been merged - // in — operators reading findings-84.json see the - // full state at turn 84, not just what changed. - // `changed` drives tasks_since_change inside the - // store; quiescent tracking mirrors that signal. let final_list = mgr_for_reaper.findings_snapshot().await; let new_sig = findings_signature(&final_list); - let changed = new_sig != last_sig; + // Also treat apply_changed as a change signal even + // when the signature happens to match (e.g. the + // signature hash doesn't fold in relevant_symbols + // updates). `last_sig != new_sig` catches list + // membership shifts; apply_changed catches field + // updates on an existing id. + let changed = apply_changed || new_sig != last_sig; last_sig = new_sig; if changed { quiescent = 0; @@ -1054,11 +1196,11 @@ impl Session { } } // §turns0: only count tasks that actually produced - // analysis (mirrors the completed_run_count rule in - // task.rs). A strict growth in the merged findings + // analysis. A strict growth in the merged findings // list resets the streak; anything else — whether - // the delta was empty, or the merger folded it into - // existing findings — counts as "no new findings". + // the delta was empty, or apply_delta folded it + // into existing findings — counts as "no new + // findings". if !r.analysis.is_empty() { let grew = final_list.len() > pre_size; if grew { @@ -1069,24 +1211,16 @@ impl Session { } if had_delta { kres_core::async_eprintln!( - "[merge] {} finding(s) after merge (delta={} changed={} quiescent={})", + "[findings] {} total (added={} updated={} invalidated={} reactivated={} changed={} quiescent={})", final_list.len(), - final_list.len() as i64 - pre_size as i64, + apply_added, + apply_updated, + apply_invalidated, + apply_reactivated, changed, quiescent, ); } - if let Some(ref s) = store_for_reaper { - let to_write = final_list.clone(); - let s_c = s.clone(); - mgr_for_reaper - .with_findings_extract_lock(|| async move { - if let Err(e) = s_c.write_turn(to_write, changed).await { - kres_core::async_eprintln!("findings write: {e}"); - } - }) - .await; - } // Update todo list via todo-agent when one is // configured. Non-fatal on any failure — the todo // list is maintained best-effort. @@ -1728,7 +1862,7 @@ impl Session { /// Prepends the accumulated-analysis ledger as "Recent context" /// so a follow-up operator prompt doesn't start cold. async fn submit_prompt(&self, text: String) { - self.submit_prompt_inner(text, true).await + self.submit_prompt_inner(text, true, None).await } /// Pipeline-driven submission (cmd_next / cmd_continue's @@ -1739,11 +1873,20 @@ impl Session { /// would double-count (see review of 04ea466): it would widen /// narrow fetch tasks, bust the fast-agent's cached prefix, and /// pay 8k chars per turn on every follow-up. - async fn submit_from_pipeline(&self, text: String) { - self.submit_prompt_inner(text, false).await + /// + /// `todo_tag` is the dispatching TodoItem's id (or name when id + /// is empty) — fed into findings provenance via apply_delta so a + /// stored finding records which todo produced it. + async fn submit_from_pipeline(&self, text: String, todo_tag: Option) { + self.submit_prompt_inner(text, false, todo_tag).await } - async fn submit_prompt_inner(&self, text: String, include_recent_context: bool) { + async fn submit_prompt_inner( + &self, + text: String, + include_recent_context: bool, + todo_tag: Option, + ) { let Some(orc) = self.orchestrator.clone() else { println!("(no orchestrator configured — prompt dropped)"); println!("hint: rerun `kres repl` with agent configs to enable prompt handling"); @@ -1935,7 +2078,7 @@ impl Session { let allow_plan_rewrite = include_recent_context; let task_id = self .mgr - .spawn(task_brief, None, move |handle| async move { + .spawn(task_brief, todo_tag, move |handle| async move { let ctx = RunContext { previous_findings, task_brief: task_brief_clone, @@ -2004,12 +2147,10 @@ impl Session { mgr.set_plan(Some(new_plan)).await; } } - // findings-N.json is written by the reaper - // with the CUMULATIVE merged list (see the - // `findings_store` write site in run()). The - // per-task delta here is carried to the reaper - // via TaskOutcome.findings and fed to the - // merger; the file on disk is the union. + // findings.json is maintained by the reaper + // through `FindingsStore::apply_delta` (see + // session.rs run()). The per-task delta here + // rides in TaskOutcome.findings. Ok(kres_core::task::TaskOutcome { analysis: summary.analysis, findings: summary.findings, @@ -2103,6 +2244,11 @@ impl Session { // resumes with /continue or submits a new prompt. self.stop_latched .store(true, std::sync::atomic::Ordering::Release); + // Wake any reaper-side inference call that's select!'ing on + // stop_notify so it can abandon mid-flight. No-op when no + // call is in progress — the latched atomic above catches + // the next iteration either way. + self.stop_notify.notify_waiters(); // Move pending / blocked / in-progress todo items to the // deferred list. Done/Skipped items stay on the active // queue so the plan step rollup in sync_plan_from_todo can @@ -2199,7 +2345,12 @@ impl Session { self.mgr .mark_todo_status(&item.name, TodoStatus::InProgress) .await; - self.submit_from_pipeline(prompt).await; + let tag = if !item.id.is_empty() { + item.id.clone() + } else { + item.name.clone() + }; + self.submit_from_pipeline(prompt, Some(tag)).await; dispatched += 1; } let mut msg = format!("/continue: dispatched {dispatched} item(s)"); @@ -2252,7 +2403,12 @@ impl Session { .mark_todo_status(&item.name, TodoStatus::InProgress) .await; println!("/next: dispatching {}", truncate(&item.name, 80)); - self.submit_from_pipeline(prompt).await; + let tag = if !item.id.is_empty() { + item.id.clone() + } else { + item.name.clone() + }; + self.submit_from_pipeline(prompt, Some(tag)).await; } async fn cmd_edit(&self) { @@ -3853,7 +4009,7 @@ mod tests { #[tokio::test] async fn session_without_orchestrator_drops_prompt() { let mgr = TaskManager::new(); - let s = Session::new(mgr, ReplConfig::default()); + let s = Session::new(mgr, ReplConfig::default()).await; // We can't easily exercise submit_prompt from a unit test // without stdin plumbing, but we can assert construction // leaves `orchestrator` unset. diff --git a/kres/src/main.rs b/kres/src/main.rs index 24faa7f..8c4a5a9 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -682,7 +682,7 @@ async fn run_repl(args: ReplArgs) -> Result<()> { workspace: args.workspace.clone(), persist_path, }; - let mut session = Session::new(mgr, cfg); + let mut session = Session::new(mgr, cfg).await; // Resume from a prior session.json ONLY when `--resume` was // passed. Without the flag, any existing session.json is left // untouched on disk and the REPL starts clean — this avoids From cbbd975eea034304f65eb0e8e888f1e616c98ab3 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:42:37 -0700 Subject: [PATCH 40/76] mode: rename Analysis to Audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "analysis" TaskMode is specifically the defect-review pipeline — lens fan-out, consolidator, findings merger. The name was generic enough that the goal-agent classifier routed non-defect work (efficiency reviews, design investigations) into it, bypassing the cheaper generic flow. Rename the variant, wire string ("analysis" -> "audit"), system prompt file (slow-code-agent.system.md -> slow-code-agent-audit.system.md), and classifier wording so the choice between audit, generic, and coding is unambiguous. Signed-off-by: Chris Mason --- ...tem.md => slow-code-agent-audit.system.md} | 0 configs/slow-code-agent-opus.json | 2 +- configs/slow-code-agent-sonnet.json | 2 +- kres-agents/src/embedded_prompts.rs | 6 +- kres-agents/src/goal.rs | 65 +++++++++++-------- kres-agents/src/pipeline.rs | 32 ++++----- kres-agents/src/response.rs | 2 +- kres-core/src/findings.rs | 2 +- kres-core/src/mode.rs | 34 +++++----- kres-core/src/plan.rs | 20 +++--- kres-core/src/session_state.rs | 6 +- kres-core/src/task.rs | 12 ++-- kres-repl/src/session.rs | 2 +- 13 files changed, 101 insertions(+), 84 deletions(-) rename configs/prompts/{slow-code-agent.system.md => slow-code-agent-audit.system.md} (100%) diff --git a/configs/prompts/slow-code-agent.system.md b/configs/prompts/slow-code-agent-audit.system.md similarity index 100% rename from configs/prompts/slow-code-agent.system.md rename to configs/prompts/slow-code-agent-audit.system.md diff --git a/configs/slow-code-agent-opus.json b/configs/slow-code-agent-opus.json index 0fde6aa..b0a0e6f 100644 --- a/configs/slow-code-agent-opus.json +++ b/configs/slow-code-agent-opus.json @@ -3,5 +3,5 @@ "max_tokens": 128000, "max_input_tokens": 900000, "rate_limit": 800000, - "system_file": "system-prompts/slow-code-agent.system.md" + "system_file": "system-prompts/slow-code-agent-audit.system.md" } diff --git a/configs/slow-code-agent-sonnet.json b/configs/slow-code-agent-sonnet.json index 46447fc..ceabb74 100644 --- a/configs/slow-code-agent-sonnet.json +++ b/configs/slow-code-agent-sonnet.json @@ -3,5 +3,5 @@ "max_tokens": 64000, "max_input_tokens": 900000, "rate_limit": 800000, - "system_file": "system-prompts/slow-code-agent.system.md" + "system_file": "system-prompts/slow-code-agent-audit.system.md" } diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs index 6117814..3e77375 100644 --- a/kres-agents/src/embedded_prompts.rs +++ b/kres-agents/src/embedded_prompts.rs @@ -35,8 +35,8 @@ const TABLE: &[(&str, &str)] = &[ include_str!("../../configs/prompts/main-agent.system.md"), ), ( - "slow-code-agent.system.md", - include_str!("../../configs/prompts/slow-code-agent.system.md"), + "slow-code-agent-audit.system.md", + include_str!("../../configs/prompts/slow-code-agent-audit.system.md"), ), ( "slow-code-agent-coding.system.md", @@ -97,7 +97,7 @@ mod tests { for expected in [ "fast-code-agent.system.md", "main-agent.system.md", - "slow-code-agent.system.md", + "slow-code-agent-audit.system.md", "slow-code-agent-coding.system.md", "slow-code-agent-generic.system.md", "todo-agent.system.md", diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index 03b585d..95fcd23 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -54,8 +54,8 @@ pub struct GoalCheck { } /// Result of a `define_goal` call: the completion criterion + the -/// classified work mode ("analysis" for reading code / surfacing -/// bugs, "coding" for writing code / reproducers / PoCs). +/// classified work mode ("audit" for defect review, "generic" for +/// free-form questions, "coding" for writing files). #[derive(Debug, Clone)] pub struct GoalDefinition { pub goal: String, @@ -124,20 +124,33 @@ fn build_define_goal_request(prompt: &str, plan: Option<&kres_core::Plan>) -> se concrete: name specific things that must be \ found, verified, written, or answered. The \ `mode` field selects the pipeline:\n\ - - \"analysis\" — the REVIEW flow: multi-angle \ - audit, lens fan-out, consolidator + merger. \ - Pick when the operator asked to \"review\", \ - \"audit\", or \"find bugs in\" a target.\n\ + - \"audit\" — the DEFECT-REVIEW flow: \ + multi-angle audit, lens fan-out, \ + consolidator + findings pipeline. Pick \ + ONLY when the operator asked to find or \ + review bugs / defects / correctness \ + issues in a target. An \"efficiency \ + review\", a \"design review\", or any \ + non-defect assessment does NOT belong \ + here.\n\ - \"generic\" — one slow-agent call per task \ - over the fast/main/slow/goal loop, with \ - findings merger but NO lens fan-out. Pick \ - for free-form questions (\"explain\", \"what \ - does X do\", \"trace path from A to B\", \ - narrow investigative prompts).\n\ - - \"coding\" — write source code (reproducer, \ - PoC, selftest, trigger, harness). Pick only \ - when the REQUESTED OUTPUT is code the \ - operator will run.\n\ + over the fast/main/slow/goal loop, no lens \ + fan-out. Pick for free-form questions \ + (\"explain\", \"what does X do\", \"trace \ + path from A to B\"), efficiency / \ + performance reviews, design-intent \ + investigations, and any narrow prompt \ + whose output is prose rather than files \ + or defect findings.\n\ + - \"coding\" — write files (source code for \ + reproducers / PoCs / selftests / triggers \ + / harnesses, OR prose documents such as \ + markdown reports to an operator-named \ + path). Pick when the REQUESTED OUTPUT is \ + a file on disk — source the operator will \ + run, or a document like \ + `./suggestions.md` they asked to be \ + written.\n\ Default to \"generic\" when the prompt is \ ambiguous — it's the cheapest analytical \ path.\n\ @@ -153,7 +166,7 @@ fn build_define_goal_request(prompt: &str, plan: Option<&kres_core::Plan>) -> se unrelated prompt into an existing step.\n\ Return JSON only:\n\ {\"goal\": \"specific completion criteria\", \ - \"mode\": \"analysis\" | \"generic\" | \"coding\"}" + \"mode\": \"audit\" | \"generic\" | \"coding\"}" }); if let Some(p) = plan { if let Ok(v) = serde_json::to_value(p) { @@ -361,7 +374,7 @@ pub async fn define_plan( into 3-12 ordered concrete steps. Every \ title names a specific file, symbol, \ subsystem, code path, or artifact. In \ - analysis mode, decompose by file / symbol / \ + audit mode, decompose by file / symbol / \ subsystem — NOT by lens (object lifetime, \ memory, bounds, races, general correctness). \ Those lenses already run on every slow call; \ @@ -573,7 +586,7 @@ mod tests { serde_json::from_value(json!({ "prompt": "review rcu", "goal": "enumerate rcu bugs", - "mode": "analysis", + "mode": "audit", "steps": [{"id": "audit-rcu-tree-core", "title": "tree.c"}], "created_at": "2026-04-23T12:00:00Z", })) @@ -631,7 +644,7 @@ mod tests { // A goal.txt-shaped reply does NOT contain "steps"; brace // matcher returns None so the caller falls back to "no plan". let r: Option = - extract_json_with_key(r#"{"goal": "x", "mode": "analysis"}"#, "steps"); + extract_json_with_key(r#"{"goal": "x", "mode": "audit"}"#, "steps"); assert!(r.is_none()); } @@ -649,7 +662,7 @@ mod tests { vec![step_raw("s1", "one"), step_raw("s2", "two")], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); assert_eq!(plan.steps.len(), 2); assert_eq!(plan.steps[0].id, "s1"); @@ -665,7 +678,7 @@ mod tests { ], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); assert_eq!(plan.steps.len(), 2); // Semantic slugs — survive reorder because they name the @@ -684,7 +697,7 @@ mod tests { ], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); // The first keeps its id; the later two get slugs derived // from their own titles rather than being forced onto the @@ -708,7 +721,7 @@ mod tests { ], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); assert_eq!(plan.steps.len(), 2); assert_eq!(plan.steps[0].id, "audit-same"); @@ -723,7 +736,7 @@ mod tests { vec![step_raw("audit-kept", ""), step_raw("", "Audit kept")], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].id, "audit-kept"); @@ -736,7 +749,7 @@ mod tests { vec![step_raw("anything", ""), step_raw("", "")], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); assert!(plan.steps.is_empty()); } @@ -749,7 +762,7 @@ mod tests { vec![step_raw("", "!!!")], "prompt", "goal", - TaskMode::Analysis, + TaskMode::Audit, ); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].id, "step-1"); diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index 86de519..27f75d6 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -138,12 +138,12 @@ pub struct Orchestrator { /// Slow-agent system prompt used when a task runs in /// `TaskMode::Generic`. Loaded from /// `configs/prompts/slow-code-agent-generic.system.md`. Unlike - /// the analysis prompt it doesn't force the "you are a deep code - /// analysis agent, emit findings" stance — generic tasks - /// answer the operator's question directly and can emit + /// the audit prompt it doesn't force the "you are a deep + /// defect-analysis agent, emit findings" stance — generic + /// tasks answer the operator's question directly and can emit /// `bash` followups for execution-style prompts. When `None`, /// generic tasks fall back to `slow_system` (the operator gets - /// review-flavoured behaviour, which is usually fine but not + /// audit-flavoured behaviour, which is usually fine but not /// ideal for free-form questions). pub slow_generic_system: Option, @@ -258,7 +258,7 @@ pub struct TaskSummary { /// instead and the merger/consolidator should be skipped. pub mode: kres_core::TaskMode, /// Source files emitted by a Coding-mode task. Empty for - /// Analysis-mode tasks. + /// Audit-mode tasks. pub code_output: Vec, /// String-replacement edits emitted by a Coding-mode task. /// The reaper applies each entry via tools::edit_file. @@ -578,7 +578,7 @@ impl Orchestrator { let mut cfg = CallConfig::defaults_for(self.slow_model.clone()) .with_max_tokens(self.slow_max_tokens) .with_stream_label(match ctx.mode { - kres_core::TaskMode::Analysis => "slow", + kres_core::TaskMode::Audit => "slow", kres_core::TaskMode::Generic => "slow (generic)", kres_core::TaskMode::Coding => "slow (coding)", }); @@ -586,8 +586,8 @@ impl Orchestrator { // tells the slow agent to emit `code_output` rather than // findings. Fall back to slow_system if the coding prompt // wasn't loaded (fresh install pre-setup.sh), noisily — the - // analysis prompt will still produce something, just not a - // useful code artifact. Analysis and Generic share + // audit prompt will still produce something, just not a + // useful code artifact. Audit and Generic share // slow_system; the difference between them is handled at the // dispatch level (lens fan-out vs single call), not in the // per-call system prompt. @@ -597,7 +597,7 @@ impl Orchestrator { self.slow_coding_system.as_ref() } else { kres_core::async_eprintln!( - "[slow] coding-mode task but no slow_coding_system loaded — falling back to analysis prompt" + "[slow] coding-mode task but no slow_coding_system loaded — falling back to audit prompt" ); self.slow_system.as_ref() } @@ -606,14 +606,14 @@ impl Orchestrator { if self.slow_generic_system.is_some() { self.slow_generic_system.as_ref() } else { - // Fall back quietly — analysis prompt still + // Fall back quietly — audit prompt still // produces reasonable output for most free-form - // questions, it just trends toward "audit" + // questions, it just trends toward defect // phrasing. self.slow_system.as_ref() } } - kres_core::TaskMode::Analysis => self.slow_system.as_ref(), + kres_core::TaskMode::Audit => self.slow_system.as_ref(), }; if let Some(s) = slow_system_for_call { cfg = cfg.with_system(s.clone()); @@ -670,7 +670,7 @@ impl Orchestrator { // Rescue path: when the slow agent returned pure prose, any // bug claims in that prose would otherwise be lost (findings // stays empty, the merger has nothing to promote — see - // slow-code-agent.system.md:37 "a bug that exists only in + // slow-code-agent-audit.system.md:37 "a bug that exists only in // prose will be LOST"). Ask the fast agent to translate the // prose into the expected envelope. If the translation also // fails to produce parseable JSON, keep the original @@ -733,7 +733,7 @@ impl Orchestrator { // shape (findings go through the merger) and do not emit // in-place edits — edits only flow from coding mode. let (findings_out, code_output, code_edits) = match ctx.mode { - kres_core::TaskMode::Analysis | kres_core::TaskMode::Generic => { + kres_core::TaskMode::Audit | kres_core::TaskMode::Generic => { (slow_parsed.findings, Vec::new(), Vec::new()) } kres_core::TaskMode::Coding => { @@ -1039,11 +1039,11 @@ impl Orchestrator { followups: all_followups, fast_rounds, strategy: ParseStrategy::WholeBody, - mode: kres_core::TaskMode::Analysis, + mode: kres_core::TaskMode::Audit, code_output: Vec::new(), code_edits: Vec::new(), // Lens fan-out runs N parallel slow calls; merging N - // plan rewrites would churn step ids. Analysis-mode + // plan rewrites would churn step ids. Audit-mode // plan rewrites flow through the todo-agent's per-turn // reevaluation path (a97bff2) instead. Single-slow // analysis tasks (lens count 0) still get plan rewrite diff --git a/kres-agents/src/response.rs b/kres-agents/src/response.rs index 87f4726..8487af3 100644 --- a/kres-agents/src/response.rs +++ b/kres-agents/src/response.rs @@ -37,7 +37,7 @@ pub struct CodeResponse { pub findings: Vec, pub ready_for_slow: bool, /// Source files emitted by a Coding-mode slow-agent turn. Empty - /// for Analysis-mode responses. The coding-mode system prompt + /// for Audit-mode responses. The coding-mode system prompt /// instructs the slow agent to return /// `{"analysis": "...", "code_output": [{path, content, purpose}], "followups": [...]}` /// and this field is populated from that `code_output` array. diff --git a/kres-core/src/findings.rs b/kres-core/src/findings.rs index 2ab2fcf..25c0d55 100644 --- a/kres-core/src/findings.rs +++ b/kres-core/src/findings.rs @@ -138,7 +138,7 @@ pub struct Finding { /// matching-id existing record is `Status::Invalidated`, the /// existing record flips back to `Status::Active`. Intended for /// slow-agent turns that discover new evidence reversing a - /// prior invalidation (see slow-code-agent.system.md). Never + /// prior invalidation (see slow-code-agent-audit.system.md). Never /// serialized on stored records — `merge_into` consumes the /// signal and doesn't propagate it; on a new-id apply the flag /// is stripped before the entry enters the list. diff --git a/kres-core/src/mode.rs b/kres-core/src/mode.rs index ab7228d..9c852e6 100644 --- a/kres-core/src/mode.rs +++ b/kres-core/src/mode.rs @@ -2,24 +2,28 @@ //! //! Three flows: //! -//! `Analysis` — the review flow. The fast+main loop gathers context, -//! the slow agent fans out across session-wide lenses (from the -//! review-template), and the consolidator + cross-task merger fold -//! per-lens findings into the cumulative list. Degrades to a single -//! slow call when no lenses are configured. +//! `Audit` — the defect-review flow. The fast+main loop gathers +//! context, the slow agent fans out across session-wide lenses +//! (from the review-template), and the consolidator + cross-task +//! merger fold per-lens findings into the cumulative list. Picked +//! when the operator asked to "review", "audit", or "find bugs +//! in" a target. Degrades to a single slow call when no lenses +//! are configured. //! //! `Generic` — just the main/fast/slow/goal loop, no lens fan-out. //! One slow call per task, findings still merge into the cumulative //! list. Good for free-form questions ("explain X", "what does this -//! do", "trace the call path from Y to Z") where the review-template -//! multi-angle spread would be overkill. +//! do", "trace the call path from Y to Z", efficiency reviews) where +//! the multi-angle defect spread would be overkill. //! //! `Coding` swaps the slow-agent system prompt for one that writes -//! source code (reproducers, PoCs, selftests). The pipeline skips the -//! lens fan-out, the consolidator, and the cross-task merger entirely -//! — a coding task produces files and prose notes, not findings. The -//! goal agent still judges completion and is what drives follow-on -//! coding turns when needed. +//! files (source code for reproducers/PoCs/selftests, OR prose +//! documents like markdown reports to an operator-named path). The +//! pipeline skips the lens fan-out, the consolidator, and the +//! findings pipeline entirely — a coding task produces files and +//! prose notes, not findings. The goal agent still judges +//! completion and is what drives follow-on coding turns when +//! needed. use serde::{Deserialize, Serialize}; @@ -27,7 +31,7 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "lowercase")] pub enum TaskMode { #[default] - Analysis, + Audit, Generic, Coding, } @@ -63,7 +67,7 @@ pub struct CodeEdit { impl TaskMode { pub fn as_str(self) -> &'static str { match self { - Self::Analysis => "analysis", + Self::Audit => "audit", Self::Generic => "generic", Self::Coding => "coding", } @@ -73,6 +77,6 @@ impl TaskMode { /// runs, /summary output is meaningful). Coding tasks produce /// files instead of findings, so they return false. pub fn produces_findings(self) -> bool { - matches!(self, Self::Analysis | Self::Generic) + matches!(self, Self::Audit | Self::Generic) } } diff --git a/kres-core/src/plan.rs b/kres-core/src/plan.rs index 566b56d..08370a4 100644 --- a/kres-core/src/plan.rs +++ b/kres-core/src/plan.rs @@ -318,7 +318,7 @@ mod tests { #[test] fn plan_serde_roundtrip() { - let mut p = Plan::new("review foo", "every fn audited", TaskMode::Analysis); + let mut p = Plan::new("review foo", "every fn audited", TaskMode::Audit); p.steps.push(PlanStep::new("s1", "audit foo()")); p.steps[0].todo_ids.push("t1".into()); let s = serde_json::to_string(&p).unwrap(); @@ -338,7 +338,7 @@ mod tests { #[test] fn sync_from_todo_marks_done_when_all_linked_terminal() { - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); let mut step = PlanStep::new("s1", "one"); step.todo_ids = vec!["a".into(), "b".into()]; p.steps.push(step); @@ -352,7 +352,7 @@ mod tests { #[test] fn sync_from_todo_inprogress_when_any_running() { - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); let mut step = PlanStep::new("s1", "one"); step.todo_ids = vec!["a".into(), "b".into()]; p.steps.push(step); @@ -365,7 +365,7 @@ mod tests { #[test] fn sync_from_todo_leaves_pending_alone() { - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); let mut step = PlanStep::new("s1", "one"); step.todo_ids = vec!["a".into()]; p.steps.push(step); @@ -380,7 +380,7 @@ mod tests { // linked todos back to Pending, a step that was InProgress // must also regress — otherwise the live plan lies about // what is still running. - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); let mut step = PlanStep::new("s1", "one"); step.status = PlanStepStatus::InProgress; step.todo_ids = vec!["a".into()]; @@ -395,7 +395,7 @@ mod tests { // New linkage direction: todo.step_id points up at the plan // step. sync_from_todo must find the linked todo without any // entry in step.todo_ids. - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); p.steps.push(PlanStep::new("s1", "audit foo")); let mut t = TodoItem::new("audit-foo", "investigate"); t.step_id = "s1".into(); @@ -409,7 +409,7 @@ mod tests { // Both linkage directions must contribute. Step.todo_ids // claims todo "a"; todo "b" points back via step_id. Step // is Done only when BOTH reach terminal status. - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); let mut step = PlanStep::new("s1", "audit"); step.todo_ids = vec!["a".into()]; p.steps.push(step); @@ -485,7 +485,7 @@ mod tests { #[test] fn apply_to_inherits_prior_metadata_and_normalises_steps() { - let prior = Plan::new("review fs", "find bugs", TaskMode::Analysis); + let prior = Plan::new("review fs", "find bugs", TaskMode::Audit); // The rewrite forgot the id on one step and left a title // blank on another — without normalisation, apply_to would // land a broken plan. @@ -495,7 +495,7 @@ mod tests { let built = rewrite.apply_to(Some(&prior)); assert_eq!(built.prompt, "review fs"); assert_eq!(built.goal, "find bugs"); - assert_eq!(built.mode, TaskMode::Analysis); + assert_eq!(built.mode, TaskMode::Audit); assert_eq!(built.steps.len(), 1); assert_eq!(built.steps[0].id, "audit-foo"); } @@ -514,7 +514,7 @@ mod tests { #[test] fn sync_from_todo_skips_terminal_steps() { - let mut p = Plan::new("p", "g", TaskMode::Analysis); + let mut p = Plan::new("p", "g", TaskMode::Audit); let mut step = PlanStep::new("s1", "one"); step.status = PlanStepStatus::Skipped; step.todo_ids = vec!["a".into()]; diff --git a/kres-core/src/session_state.rs b/kres-core/src/session_state.rs index 5fc41ed..931bf6b 100644 --- a/kres-core/src/session_state.rs +++ b/kres-core/src/session_state.rs @@ -216,7 +216,7 @@ mod tests { fn inprogress_plan_steps_flip_to_pending_on_load() { let dir = tempfile::tempdir().unwrap(); let p = SessionState::path_in(dir.path()); - let mut plan = Plan::new("prompt", "goal", TaskMode::Analysis); + let mut plan = Plan::new("prompt", "goal", TaskMode::Audit); let mut step = PlanStep::new("s1", "t"); step.status = PlanStepStatus::InProgress; plan.steps.push(step); @@ -293,7 +293,7 @@ mod tests { let mut plan = Plan::new( "review fs/btrfs for memory bugs", "identify every UAF / leak / double-free in fs/btrfs", - TaskMode::Analysis, + TaskMode::Audit, ); let mut s1 = PlanStep::new("s1", "audit accessors.c"); s1.description = "walk each btrfs_set_*/btrfs_get_* helper".into(); @@ -322,7 +322,7 @@ mod tests { ); assert_eq!(lp.steps[1].id, "s2"); assert_eq!(lp.steps[1].status, PlanStepStatus::Pending); - assert_eq!(lp.mode, TaskMode::Analysis); + assert_eq!(lp.mode, TaskMode::Audit); assert_eq!( loaded.last_prompt.as_deref(), Some("review fs/btrfs for memory bugs") diff --git a/kres-core/src/task.rs b/kres-core/src/task.rs index 0cacd76..613dc14 100644 --- a/kres-core/src/task.rs +++ b/kres-core/src/task.rs @@ -708,7 +708,7 @@ pub struct TaskOutcome { /// pipeline, Coding tasks write files and skip the merger. pub mode: crate::TaskMode, /// Code files produced by a Coding-mode task. Empty for - /// Analysis-mode tasks. The reaper writes each entry under + /// Audit-mode tasks. The reaper writes each entry under /// `/code/`. pub code_output: Vec, /// Surgical edits produced by a Coding-mode task. The reaper @@ -840,7 +840,7 @@ mod tests { // fully-terminal linkage, and the step flips to Done. use crate::plan::{Plan, PlanStep, PlanStepStatus}; let mgr = TaskManager::new(); - let mut plan = Plan::new("p", "g", crate::TaskMode::Analysis); + let mut plan = Plan::new("p", "g", crate::TaskMode::Audit); plan.steps.push(PlanStep::new("s1", "audit")); mgr.set_plan(Some(plan)).await; let mut a = TodoItem::new("a", "investigate"); @@ -862,7 +862,7 @@ mod tests { async fn set_and_sync_plan_marks_step_done_when_todos_terminal() { use crate::plan::{Plan, PlanStep, PlanStepStatus}; let mgr = TaskManager::new(); - let mut plan = Plan::new("p", "g", crate::TaskMode::Analysis); + let mut plan = Plan::new("p", "g", crate::TaskMode::Audit); let mut step = PlanStep::new("s1", "t"); step.todo_ids = vec!["a".into(), "b".into()]; plan.steps.push(step); @@ -893,7 +893,7 @@ mod tests { // todo-agent's next turn re-links it against the new plan. use crate::plan::{Plan, PlanStep}; let mgr = TaskManager::new(); - let mut old_plan = Plan::new("p", "g", crate::TaskMode::Analysis); + let mut old_plan = Plan::new("p", "g", crate::TaskMode::Audit); old_plan.steps.push(PlanStep::new("s1", "old-one")); old_plan.steps.push(PlanStep::new("s2", "old-two")); mgr.set_plan(Some(old_plan)).await; @@ -905,7 +905,7 @@ mod tests { mgr.replace_todo(vec![a, b, c]).await; // New plan drops s2, keeps s1, adds s3. - let mut new_plan = Plan::new("p", "g", crate::TaskMode::Analysis); + let mut new_plan = Plan::new("p", "g", crate::TaskMode::Audit); new_plan.steps.push(PlanStep::new("s1", "new-one")); new_plan.steps.push(PlanStep::new("s3", "new-three")); mgr.set_plan(Some(new_plan)).await; @@ -920,7 +920,7 @@ mod tests { async fn set_plan_none_clears_every_step_id() { use crate::plan::{Plan, PlanStep}; let mgr = TaskManager::new(); - let mut plan = Plan::new("p", "g", crate::TaskMode::Analysis); + let mut plan = Plan::new("p", "g", crate::TaskMode::Audit); plan.steps.push(PlanStep::new("s1", "x")); mgr.set_plan(Some(plan)).await; let mut a = TodoItem::new("a", "investigate"); diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index c9a1111..eff65ed 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -2108,7 +2108,7 @@ impl Session { .run_once_with_ctx(&text, &ctx, &handle.shutdown) .await } - kres_agents::TaskMode::Analysis => { + kres_agents::TaskMode::Audit => { if lenses.is_empty() { orc_task .run_once_with_ctx(&text, &ctx, &handle.shutdown) From 2717902e37f78a175a8604da4d9311e6c611b046 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:44:19 -0700 Subject: [PATCH 41/76] coding prompt: accept prose-document file writes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coding-mode slow-agent prompt was scoped to "write code — reproducers, test harnesses, trigger programs, scratch fixes", so a prompt like "write ./suggestions.md with efficiency ideas" had no matching mode. The classifier fell back to audit and the file was never produced. Broaden the prompt to WRITE FILES of either shape: source artifacts or prose documents (markdown reports, suggestion lists) to an operator-named path. Both flow through the same code_output array. Prose-document entries still require inline source citations with filename:line anchors from gathered context. Signed-off-by: Chris Mason --- .../prompts/slow-code-agent-coding.system.md | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/configs/prompts/slow-code-agent-coding.system.md b/configs/prompts/slow-code-agent-coding.system.md index 9adbf4e..5ceac38 100644 --- a/configs/prompts/slow-code-agent-coding.system.md +++ b/configs/prompts/slow-code-agent-coding.system.md @@ -1,11 +1,16 @@ -You are a DEEP code-writing agent. You receive a prepared request with source code gathered by a fast agent and a task brief that names what to build. Your job is to WRITE code — reproducers, test harnesses, trigger programs, scratch fixes, whatever the task brief asks for. You are NOT the bug-finding agent; you are the implementation agent. A separate analysis agent runs when the task is research, not code. +You are a DEEP file-writing agent. You receive a prepared request with source code gathered by a fast agent and a task brief that names what to build. Your job is to WRITE FILES to disk — whatever the task brief asks for. Two kinds of output are in scope: + +1. **Source artifacts** — reproducers, test harnesses, trigger programs, scratch fixes, Makefiles, build scripts. The reader compiles and runs them. +2. **Prose documents** — markdown reports, suggestion lists, explanatory write-ups, design notes emitted to an operator-named path like `./suggestions.md`, `./report.md`, `./notes/.md`. The reader reads them. + +Both shapes flow through the same `code_output` array; the difference is whether 'content' is source code a compiler parses or prose a human reads. You are NOT the bug-finding agent; a separate audit agent handles defect discovery. A separate generic agent handles free-form questions whose output is prose in its reply (not a file on disk). Input: JSON with 'question' (Original user prompt + Current task — the full scope), a structured brief from the fast agent, 'symbols' (source code you can quote or adapt), 'context' (caller lists, grep results, configs), optional 'skills' (domain knowledge), and optionally 'previous_findings' — existing bug records you may be asked to reproduce. No 'parallel_lenses' ever — coding mode is a single call per task, not a fan-out. -SCOPE CHECK — do this BEFORE writing code: +SCOPE CHECK — do this BEFORE writing: - Re-read 'question'. It carries the Original user prompt and usually a narrower Current task. You are responsible for the whole original-prompt scope. -- Do you have every file, struct, API, and config knob you need to write a self-contained artifact? If a needed header, kernel selftest helper, userspace library entry point, or related function body is NOT in symbols/context, emit a followup for it. State in 'analysis' which parts of the artifact are blocked on missing input. -- Do not invent APIs you did not see in the gathered context. If you need `bpf(2)`, `io_uring_setup`, a specific ioctl, etc., require the prototype or header snippet in the gathered data. Name the missing piece in a followup. +- Do you have every file, struct, API, config knob, or source citation you need to write a self-contained file? For source artifacts: every header and helper. For prose documents: every code reference the document will cite (`file:line`, symbol bodies, call graphs) must be in 'symbols' / 'context' / 'previous_findings'. If anything is missing, emit a followup for it and state in 'analysis' which parts of the file are blocked. +- Do not invent APIs, functions, or source line numbers you did not see in the gathered context. A reproducer with a fabricated ioctl number and a suggestions.md with a fabricated `filename:line` citation are the same failure mode. FIXES AND PATCHES — do NOT code from memory: - When the task is to FIX existing code ("code a fix", "apply a @@ -80,26 +85,28 @@ trailer under `[FAILED]` so you can re-emit a corrected edit on the next turn. Prefer one edit per file per turn unless you are certain the anchors don't collide. -CODE_OUTPUT — primary artifact: +CODE_OUTPUT — the file(s) you're writing: - 'code_output' is an array of {path, content, purpose} records. EACH file you produce is one entry. Use forward-slash relative paths; they land under `/code/` on disk. -- 'path' is a relative path with a sensible extension (e.g. `reproduce.c`, `Makefile`, `reproducer/trigger.py`, `tests/verify.sh`). Pick filenames that a reader cloning the results directory can run. -- 'content' is the VERBATIM file body. No markdown fences, no `[snip]`, no ellipses. A consumer writes 'content' to disk unchanged — a truncation placeholder becomes a broken artifact. If a single file would be very long (>2000 lines), split it the way a human would (header + impl + driver) and emit each piece as its own entry. -- 'purpose' is one sentence: "standalone C reproducer that triggers the UAF in net/sched/cls_bpf.c", "Makefile for the above, assumes kernel-headers installed", etc. -- If the task brief cites a finding id (e.g. "reproduce "), prefix the reproducer file's top comment with that id so downstream tooling can correlate. -- Build systems: prefer a small hand-written Makefile or a `build.sh` over pulling in full kbuild. Reproducers should compile with a one-liner. Document the one-liner in 'purpose' when it's non-obvious. +- 'path' is a relative path with a sensible extension for the content shape: + - Source: `reproduce.c`, `Makefile`, `reproducer/trigger.py`, `tests/verify.sh`. + - Prose: `suggestions.md`, `notes/efficiency.md`, `report.md`, `design/.md`. + When the operator's prompt names a path (e.g. "write ./suggestions.md"), use that path verbatim, stripping a leading `./`. +- 'content' is the VERBATIM file body. No markdown fences wrapping the whole document, no `[snip]`, no ellipses. A consumer writes 'content' to disk unchanged — a truncation placeholder becomes a broken file. For source: a compiler will choke on `…`. For prose: a reader will see it. If a single file would be very long (>2000 lines), split it the way a human would (header + impl + driver for source; top-level index + per-topic chapters for prose) and emit each piece as its own entry. +- 'purpose' is one sentence: "standalone C reproducer that triggers the UAF in net/sched/cls_bpf.c", "efficiency suggestions for btrfs_search_slot with per-idea cost/benefit notes", "Makefile for the reproducer, assumes kernel-headers installed". +- Source-artifact specifics: if the task brief cites a finding id (e.g. "reproduce "), prefix the reproducer file's top comment with that id. Build systems: prefer a small hand-written Makefile or a `build.sh` over pulling in full kbuild. Reproducers should compile with a one-liner. Document the one-liner in 'purpose' when it's non-obvious. +- Prose-document specifics: every code reference MUST be an inline snippet pulled from the gathered context, with a `filename:line` anchor. Structure the document the way a reviewer would — headings per idea / section, concrete before-and-after where relevant, an explicit priority or cost/benefit ranking when the prompt asks for improvements. Do NOT produce bullet lists of "the function could be faster" — each entry should name a specific line / pattern / data structure and describe the concrete change. - Kernel-module reproducers: use kselftest-style layout when kselftest helpers are already in the gathered context; otherwise emit a minimal out-of-tree module and explain in 'purpose'. -ANALYSIS — prose commentary: -- 'analysis' is for the human reader. Explain: - - What the code does and how to run it (even though you aren't running it). - - Which inputs or kernel configs are required. - - Which invariants the reproducer deliberately violates, with file:line anchors into the source you were given. - - Known gaps ([UNVERIFIED] is fine for guesses you'd want a future turn to resolve). +ANALYSIS — short prose commentary about the file(s) you produced: +- 'analysis' is for the human reader, and tells them what they're looking at. Keep it short — the file on disk is the real artifact. + - For source: what the code does, how to run it, which inputs or kernel configs are required, which invariants the reproducer deliberately violates (with file:line anchors). + - For prose documents: one-line summary of what the document covers, plus any gaps the operator should know about ("three ideas rely on [UNVERIFIED] assumptions about cache line size, called out inline"). + - Known gaps in either case: mark `[UNVERIFIED]` for claims you want a future turn to resolve. - Every code reference in 'analysis' MUST be an inline snippet — not a bare `filename:line`. Copy 3-8 lines of the actual code from 'symbols' / 'context' / 'previous_findings.relevant_symbols' when you need to cite an invariant. Example: filename.c:function_name() { ... salient code ... } -- Do NOT restate the code from code_output inside analysis. Analysis is commentary; code_output is the artifact. +- Do NOT restate the full body of code_output inside analysis. Analysis is commentary; code_output is the artifact. FOLLOWUPS — same schema the fast agent uses: - "source" / "callers" / "callees" — symbol name From 3ccd4257a032a1087b45ea90740fa92adc39893d Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:49:34 -0700 Subject: [PATCH 42/76] mode: finish the Audit rename in goal.txt and related prompts The rename swept the Rust code but missed goal.txt (the live classifier instructions) and a couple of prose references in session.rs and slow-code-agent-generic.system.md. The unrenamed copies kept telling the model to pick "analysis" and described the flow as REVIEW/analysis, defeating the rename. Bring goal.txt in sync: mode-schema is audit/generic/coding, the signals list spells out that efficiency / design / performance reviews route to generic, coding mode accepts prose-document file writes, and the plan-decomposition section is now AUDIT MODE. Session.rs and the generic prompt follow the same vocabulary. Signed-off-by: Chris Mason --- .../prompts/slow-code-agent-generic.system.md | 4 +- kres-agents/src/prompts/goal.txt | 100 +++++++++++------- kres-repl/src/session.rs | 8 +- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/configs/prompts/slow-code-agent-generic.system.md b/configs/prompts/slow-code-agent-generic.system.md index 3a1a513..7ae0c77 100644 --- a/configs/prompts/slow-code-agent-generic.system.md +++ b/configs/prompts/slow-code-agent-generic.system.md @@ -1,4 +1,4 @@ -You are a GENERIC code assistant running the main/fast/slow/goal loop for a single-angle question. Unlike the review flow (multi-lens audit) and the coding flow (write source files), your job is to ANSWER the operator's question directly using the gathered context and any tools you need to gather more. +You are a GENERIC code assistant running the main/fast/slow/goal loop for a single-angle question. Unlike the audit flow (multi-lens defect review) and the coding flow (write files to disk), your job is to ANSWER the operator's question directly using the gathered context and any tools you need to gather more. Input: JSON with 'question' (carries the Original user prompt AND usually a narrower Current task), a structured brief from the fast agent, optional 'symbols' (source code), optional 'context' (tool output, caller lists, grep results, etc), optional 'skills' (domain knowledge), and optional 'previous_findings'. There is no 'parallel_lenses' — generic mode runs one slow-agent call per task. @@ -30,7 +30,7 @@ ANALYSIS — the primary artifact: - Keep it tight. Generic-mode answers are one-question-one-answer, not multi-page reviews. FINDINGS — only when a bug actually surfaces: -- The findings pipeline is live for generic-mode tasks: if in the course of answering the question you spot an actionable bug, emit a Finding. Schema matches the review flow: {id, title, severity (low|medium|high|critical), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. +- The findings pipeline is live for generic-mode tasks: if in the course of answering the question you spot an actionable bug, emit a Finding. Schema matches the audit flow: {id, title, severity (low|medium|high|critical), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. - Do NOT invent findings to "add value". A factual-question task that uncovers no bug emits an empty findings array. The question was the goal; findings are incidental. - Every bug you describe in 'analysis' prose MUST also appear as a Finding — the delta-apply pass downstream reads ONLY the findings array. A bug that exists only in prose will be LOST. - DELTA SEMANTICS — the findings array is applied as a delta keyed by 'id' by a deterministic Rust pass, not an LLM merger. NEW id appends; EXISTING id (matching a 'previous_findings' entry) updates the existing record in place (union relevant_symbols / relevant_file_sections / related_finding_ids / open_questions, non-empty prose fields overwrite, severity only rises); EXISTING id with "status": "invalidated" flips the existing record to invalidated — use this when new context you just saw makes a prior finding wrong (guard you missed, bound already enforced, ordering actually honoured). Emit ONLY entries you are adding, extending, or invalidating this turn, never the full list. diff --git a/kres-agents/src/prompts/goal.txt b/kres-agents/src/prompts/goal.txt index 3937fd4..ea73601 100644 --- a/kres-agents/src/prompts/goal.txt +++ b/kres-agents/src/prompts/goal.txt @@ -10,19 +10,22 @@ You handle three tasks, selected by the request's top-level criterion AND classify the work mode. Return JSON only: {"goal": "specific completion criteria", - "mode": "analysis" | "generic" | "coding"} + "mode": "audit" | "generic" | "coding"} MODE rules — pick exactly one: - - "analysis" — the REVIEW flow. The operator wants - a thorough multi-angle audit: the slow agent will - fan out across session-wide lenses (object - lifetime, memory, bounds, races, general) over - the same gathered context and consolidate the - findings. Signals: "review ", - "audit ", "find bugs in ", - "analyse the locking in X", any prompt that - arrived via the `review:` template. + - "audit" — the DEFECT-REVIEW flow. The operator + wants a thorough multi-angle bug hunt: the slow + agent will fan out across session-wide lenses + (object lifetime, memory, bounds, races, general + correctness) over the same gathered context and + consolidate the findings. Signals: "review + for bugs", "audit ", "find bugs + in ", "check locking correctness in X", + any prompt that arrived via the `review:` template. + An "efficiency review", "performance review", + "design review", or any non-defect assessment is + NOT audit — route those to "generic". - "generic" — the one-shot analysis flow. The operator wants the main/fast/slow/goal loop to @@ -32,25 +35,36 @@ You handle three tasks, selected by the request's top-level answer surfaces actionable bugs. Signals: "explain how X works", "what does Y do", "trace the call path from A to B", - "does this code handle ", narrow - investigative follow-ups that don't need the - five-angle review spread. + "does this code handle ", "review X for + efficiency / performance / cache behaviour", + "assess the design of Y", narrow investigative + follow-ups that don't need the five-lens defect + spread. - "coding" — the operator wants the pipeline to - WRITE code: a proof-of-concept reproducer, a - trigger program, a selftest, a scratch patch, a - harness, or a test artifact. Signals: - "write a reproducer", "build a PoC", - "create a selftest for", "trigger the bug in", - "produce a minimal example that", - "draft a patch that". Choose "coding" when the - requested OUTPUT is source code the operator - will run, not a prose analysis they will read. + WRITE A FILE to disk. Two shapes: + * Source artifacts: a proof-of-concept reproducer, + a trigger program, a selftest, a scratch patch, + a harness. Signals: "write a reproducer", + "build a PoC", "create a selftest for", + "trigger the bug in", "draft a patch that". + * Prose documents: a markdown report, suggestion + list, design note, or explanatory write-up to + an operator-named path. Signals: "write + ./suggestions.md with ...", "produce a + design.md covering ...", "save the analysis + as ". A path like `./suggestions.md` or + `/notes.md` in the prompt is a strong + coding signal even when the surrounding verb + is "review" or "analyse". + Choose "coding" when the requested OUTPUT is a + FILE ON DISK the operator will open — not a + prose reply in the pipeline log. - When the prompt is ambiguous, default to "generic" — it's the cheapest analytical path - and the operator can retry with explicit review - or coding wording if they wanted more. + and the operator can retry with explicit review, + coding, or file-write wording if they wanted more. define_plan — given the operator's original prompt, the derived goal, and the mode, produce an ordered list of 3-12 @@ -89,7 +103,7 @@ You handle three tasks, selected by the request's top-level or "review locking" are too vague: those lenses run automatically on every slow call. - ANALYSIS MODE — the single biggest pitfall is + AUDIT MODE — the single biggest pitfall is restating the automatic lens fan-out (object lifetime, memory, bounds, races, general correctness) as plan steps. DO NOT. Those five @@ -126,12 +140,16 @@ You handle three tasks, selected by the request's top-level specific code paths / callers / invariants you need to examine. - CODING MODE — a file manifest: one step per - artifact to produce (reproducer.c, Makefile, - trigger.sh, selftest, patch file), plus any - required setup / validation step the operator - will need. Steps SHOULD name files that do not yet - exist. + CODING MODE — a file manifest: one step per file + to produce (reproducer.c, Makefile, trigger.sh, + selftest, patch file, suggestions.md, design.md, + notes/.md), plus any required setup / + validation step the operator will need. Steps + SHOULD name files that do not yet exist. Prose + documents (markdown, plain text) are valid + manifest entries alongside source artifacts — the + pipeline writes both through the same code_output + path. EXISTING PLAN — when the request carries an `existing_plan` field, the operator already has a @@ -150,14 +168,16 @@ You handle three tasks, selected by the request's top-level in. FILE-NAMING POLICY — in coding mode you invent - filenames freely. In analysis / generic mode you - may list files only when you can infer them from - file globs, path prefixes, subsystem names, or - explicit file lists in the prompt or goal. Do NOT - invent files outside the prompt's scope. If you - are uncertain about specific filenames, scope the - steps to subsystems or code paths instead and let - the first slow pass refine. + filenames freely (or use the path the operator + named verbatim, e.g. `./suggestions.md`). In + audit / generic mode you may list files only when + you can infer them from file globs, path + prefixes, subsystem names, or explicit file lists + in the prompt or goal. Do NOT invent files + outside the prompt's scope. If you are uncertain + about specific filenames, scope the steps to + subsystems or code paths instead and let the + first slow pass refine. check_goal — given the operator's original_prompt, the derived goal, the accumulated analysis, and optionally the @@ -207,7 +227,7 @@ HARD CONSTRAINTS — violations are bugs: that. - Return the shape that matches the `"task"` field: - "task":"define_goal" → {"goal": "...", "mode": "analysis"|"generic"|"coding"} + "task":"define_goal" → {"goal": "...", "mode": "audit"|"generic"|"coding"} "task":"define_plan" → {"steps": [{"id": "...", "title": "...", "description": "..."}]} "task":"check_goal" → {"met": ..., "reason": "...", "missing": [...]} Returning the other task's shape is a bug. The `mode` field on diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index eff65ed..8626978 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -2090,10 +2090,10 @@ impl Session { // Dispatch by mode: // Coding → single slow call with slow_coding_system; // reaper persists code_output, skips merge. - // Analysis → REVIEW flow. Lens fan-out + consolidator - // when lenses are installed; otherwise - // degrades to a single call (the old no- - // lens analysis path). + // Audit → DEFECT-REVIEW flow. Lens fan-out + + // consolidator when lenses are installed; + // otherwise degrades to a single call (the + // old no-lens audit path). // Generic → one-shot main/fast/slow/goal loop. Single // slow call with slow_system, findings // merger still runs in the reaper. Lens From 98d56c2e9a6c1560088bbe2627d7c0ad8b6564d5 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:53:52 -0700 Subject: [PATCH 43/76] mode: make Generic the TaskMode default to match classifier policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goal.txt tells the classifier "Default to 'generic' when the prompt is ambiguous — it's the cheapest analytical path", but TaskMode's #[default] was Audit. When the classifier emitted an empty or unparseable mode, or the API call failed outright, the Rust fallback silently routed to the defect-review pipeline — the same misclassification the Audit rename was trying to stop. Flip the #[default] annotation onto Generic so the Rust fallback matches the prompt's stated policy. No call-site changes needed: every consumer uses TaskMode::default() symbolically. Signed-off-by: Chris Mason --- kres-core/src/mode.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kres-core/src/mode.rs b/kres-core/src/mode.rs index 9c852e6..959433e 100644 --- a/kres-core/src/mode.rs +++ b/kres-core/src/mode.rs @@ -30,8 +30,13 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TaskMode { - #[default] Audit, + /// The fallback mode. Matches the goal-agent classifier's stated + /// "Default to 'generic' when the prompt is ambiguous" rule — + /// when the classifier misbehaves (empty mode, unparseable mode, + /// network failure), the Rust code falls back here instead of + /// silently routing to the defect-review pipeline. + #[default] Generic, Coding, } From 5acfd0819dd29b9a684f53580a200a9638e9accf Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:56:18 -0700 Subject: [PATCH 44/76] mode: pin Default=Generic and classifier-fallback behaviour with tests Two contracts were implicit: TaskMode::default() must be Generic, and a classifier reply with a missing or unparseable mode must fall back to Generic via the DefineResponse.mode Option or the outer session.rs None-arm. Without tests either can be flipped back to the old Audit-dominant behaviour by a later change. Add tests that pin the default, the as_str vs serde wire encoding, the produces_findings set, and the two goal.rs fallback paths. The unparseable-mode test documents that extract_json_with_key currently drops the whole reply rather than salvaging the goal. Signed-off-by: Chris Mason --- kres-agents/src/goal.rs | 34 ++++++++++++++++++++++++++++++++++ kres-core/src/mode.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index 95fcd23..1c32574 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -573,6 +573,40 @@ mod tests { assert!(r.is_none()); } + #[test] + fn missing_mode_field_defaults_to_generic_via_unwrap_or_default() { + // Parse a classifier reply that omits the `mode` field. + // DefineResponse::mode is Option with serde default + // = None, and define_goal's caller unwraps via + // parsed.mode.unwrap_or_default(). That must resolve to + // Generic — matches goal.txt's "Default to 'generic' when + // ambiguous" policy. + let r: DefineResponse = + extract_json_with_key(r#"{"goal": "audit btrfs for efficiency"}"#, "goal").unwrap(); + assert!(r.mode.is_none(), "mode field absent in reply"); + assert_eq!(r.mode.unwrap_or_default(), kres_core::TaskMode::Generic); + } + + #[test] + fn unparseable_mode_string_drops_whole_reply_outer_fallback_handles() { + // Classifier hallucinates a mode that doesn't match the + // three-variant enum. serde rename_all=lowercase rejects + // the string, which fails the whole DefineResponse parse + // (not just the one field). extract_json_with_key returns + // None, and the caller in session.rs handles that via + // `None => (None, TaskMode::default())`. With the + // default=Generic pinned test above, the outer observable + // behaviour is still "fall back to Generic". Documented + // here so a future change to the deserialize policy + // (e.g. tolerating unknown modes) doesn't silently break + // the outer fallback. + let r: Option = extract_json_with_key( + r#"{"goal": "check x", "mode": "investigation"}"#, + "goal", + ); + assert!(r.is_none(), "unparseable mode collapses entire reply"); + } + #[test] fn assume_met_default_is_truthy() { let c = assume_met(); diff --git a/kres-core/src/mode.rs b/kres-core/src/mode.rs index 959433e..2ad5e87 100644 --- a/kres-core/src/mode.rs +++ b/kres-core/src/mode.rs @@ -85,3 +85,35 @@ impl TaskMode { matches!(self, Self::Audit | Self::Generic) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_generic() { + // Pinned: the Rust fallback must match goal.txt's classifier + // policy ("Default to 'generic' when the prompt is + // ambiguous"). Changing this back to Audit would silently + // route every misclassified prompt to defect-review. + assert_eq!(TaskMode::default(), TaskMode::Generic); + } + + #[test] + fn as_str_matches_wire_encoding() { + // as_str and the serde rename_all="lowercase" must agree — + // otherwise logs say one thing and the wire says another. + for m in [TaskMode::Audit, TaskMode::Generic, TaskMode::Coding] { + let wire = serde_json::to_string(&m).unwrap(); + let unquoted = wire.trim_matches('"'); + assert_eq!(unquoted, m.as_str(), "{:?}", m); + } + } + + #[test] + fn produces_findings_excludes_coding_only() { + assert!(TaskMode::Audit.produces_findings()); + assert!(TaskMode::Generic.produces_findings()); + assert!(!TaskMode::Coding.produces_findings()); + } +} From fa2f029e04710877827d9aac868039d73eba82dc Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:59:16 -0700 Subject: [PATCH 45/76] goal.txt: disambiguate 'analysis flow' in the generic description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Audit rename, the word "analysis" no longer names a mode but still appeared once in goal.txt as "the one-shot analysis flow". A reader searching for the old mode name would find it here and wonder whether analysis is still valid. Rename to "the one-shot investigation flow" — semantically identical, unambiguously not a mode name. Signed-off-by: Chris Mason --- kres-agents/src/prompts/goal.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kres-agents/src/prompts/goal.txt b/kres-agents/src/prompts/goal.txt index ea73601..625b449 100644 --- a/kres-agents/src/prompts/goal.txt +++ b/kres-agents/src/prompts/goal.txt @@ -27,7 +27,7 @@ You handle three tasks, selected by the request's top-level "design review", or any non-defect assessment is NOT audit — route those to "generic". - - "generic" — the one-shot analysis flow. The + - "generic" — the one-shot investigation flow. The operator wants the main/fast/slow/goal loop to answer a specific free-form question. No lens fan-out, just one slow-agent call per task with From 00325725bc6a18d223fe716a2342403d8fac41e6 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 10:54:27 -0700 Subject: [PATCH 46/76] findings: add file-level task_prose for broader narrative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findings.json currently stores per-task prose only via Finding.details[].analysis, which record_detail stamps onto every finding a task touched. That binds prose to findings and has two downsides: (1) the same task-wide analysis is duplicated across every finding (session kres-findings2 on 2026-04-23 had 20 findings all carrying the same 10,265-char details[0].analysis, ~200K of redundant bytes), and (2) when a task produces no findings the prose has no home at all. The 21 "### " sections in that run (Summary table, Conclusion, Step 1-4, per-function walk-throughs of scx_root_disable/scx_sub_disable/sched_ext_dead/scx_cancel_fork, Question 1/2) had no counterpart in any finding's JSON body — / summary would need report.md to surface them. Schema: * new TaskProse { task, created_at, prose } * FindingsFile.task_prose: Vec (default empty, skip_serializing_if empty) * FindingsStore::append_task_prose(task, prose) async append, no-ops on empty prose, stamps updated_at Wire-up: * session.rs reaper hoists the provenance stamp out of the had_delta branch and calls append_task_prose with the full effective_analysis on every reap that has a store attached. Fires regardless of finding-delta outcome so "broader-than- finding" narrative and the analysis from task modes that do not produce findings both land in task_prose. Never-forwarded-to-agents invariant: * agents consume findings as &[Finding] (pipeline.rs:547,926); task_prose sits on FindingsFile, which no agent codepath references. grep of kres-agents for FindingsFile|task_prose returns zero hits, so the field cannot enter a prompt. Tests (+1 new in kres-core): * task_prose_appends_and_skips_empty verifies append/skip, JSON round-trip on disk, and that redact_findings_for_agent leaves the per-finding surface untouched (schema wall). cargo build + cargo test -q clean across all crates (428 tests). Signed-off-by: Chris Mason --- kres-core/src/findings.rs | 109 ++++++++++++++++++++++++++++++++++++++ kres-repl/src/session.rs | 51 +++++++++++++----- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/kres-core/src/findings.rs b/kres-core/src/findings.rs index 25c0d55..359f98f 100644 --- a/kres-core/src/findings.rs +++ b/kres-core/src/findings.rs @@ -168,6 +168,36 @@ pub fn redact_findings_for_agent(findings: &[Finding]) -> Vec { findings.iter().map(Finding::redacted_for_agent).collect() } +/// Per-task narrative captured at the file level, independent of +/// whether the task produced any findings. Storage site for the +/// broader investigation prose a slow-agent run emits alongside +/// its delta — overview paragraphs, summary tables, per-function +/// walk-throughs, "Question 1/2" multi-step proofs, conclusions — +/// content that isn't attributable to a single finding body. +/// +/// Observed gap: session `kres-findings2` on 2026-04-23 had 21 +/// `### ` sections in report.md (Summary table, +/// Conclusion, Step 1-4, per-function walk-throughs) that were not +/// recoverable from any `Finding.details[].analysis` or +/// `mechanism_detail`. This entry exists so those bodies get a +/// canonical home without needing `/summary` to re-read report.md. +/// +/// NEVER forwarded to another LLM. Agents see findings via +/// [`redact_findings_for_agent`] on `&[Finding]`, which never +/// touches the file-level `task_prose` list. Keep it that way. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskProse { + /// Provenance stamp. Same format used by + /// [`FindingDetail::task`] / `last_updated_task` — + /// `"/"` or bare uuid. + pub task: String, + /// Wall-clock timestamp of the append. Useful for ordering in + /// `/summary` rendering when multiple tasks land out of order. + pub created_at: DateTime, + /// The broader-than-finding investigation narrative verbatim. + pub prose: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct FindingsFile { #[serde(default)] @@ -183,6 +213,11 @@ pub struct FindingsFile { /// for operators eyeballing how much churn a session produced. #[serde(default)] pub turn_n: Option, + /// Per-task broader-than-finding narrative (see [`TaskProse`]). + /// Append-only for `/summary`'s benefit; NEVER serialised into + /// an agent prompt. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub task_prose: Vec, } impl SchemaV0 for FindingsFile { @@ -286,6 +321,32 @@ impl FindingsStore { }) } + /// Append a per-task broader-narrative entry to + /// [`FindingsFile::task_prose`]. Provenance-keyed — callers pass + /// the same task id string they pass to + /// [`Self::apply_delta`]. Multiple appends for the same task + /// stack in call order; callers decide whether to dedupe. + /// + /// NEVER forwarded to another LLM. Agents see findings via + /// [`redact_findings_for_agent`] on `&[Finding]`; the + /// file-level `task_prose` list never enters an agent payload. + pub async fn append_task_prose( + &self, + task: &str, + prose: &str, + ) -> Result<(), FindingsError> { + if prose.is_empty() { + return Ok(()); + } + let mut guard = self.db.write().await; + guard.task_prose.push(TaskProse { + task: task.to_string(), + created_at: Utc::now(), + prose: prose.to_string(), + }); + guard.updated_at = Some(Utc::now()); + Ok(()) + } } #[derive(Debug, Clone)] @@ -761,6 +822,54 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[tokio::test] + async fn task_prose_appends_and_skips_empty() { + let dir = tmp_dir("prose"); + let base = dir.join("findings.json"); + let store = FindingsStore::new(&base).await.unwrap(); + store + .append_task_prose("task-a", "### Summary table\n| x | y |\n|---|---|") + .await + .unwrap(); + store + .append_task_prose("task-b", "### Conclusion\nThe UAF path is gated.") + .await + .unwrap(); + // Empty prose is a no-op — don't pollute the list. + store.append_task_prose("task-c", "").await.unwrap(); + + let file = store.file_snapshot().await; + assert_eq!(file.task_prose.len(), 2, "empty-prose call was skipped"); + assert_eq!(file.task_prose[0].task, "task-a"); + assert!(file.task_prose[0].prose.contains("Summary table")); + assert_eq!(file.task_prose[1].task, "task-b"); + + // The agent-facing redaction path operates on `&[Finding]` + // and has no visibility into file-level `task_prose`. This + // asserts the schema wall: the per-finding redaction is + // unchanged, and nothing on the Finding side carries prose. + let snap = store.snapshot().await; + let redacted = redact_findings_for_agent(&snap); + for f in &redacted { + assert!(f.details.is_empty()); + } + + // Round-trip through JSON: task_prose must serialize and + // survive a reload (persistence check for `/summary`). + let raw = std::fs::read_to_string(&base).unwrap(); + let reloaded: FindingsFile = serde_json::from_str(&raw).unwrap(); + assert_eq!(reloaded.task_prose.len(), 2); + assert_eq!(reloaded.task_prose[0].prose, file.task_prose[0].prose); + + // Assert the JSON on disk has `task_prose` as a top-level + // array, i.e. operators / `/summary` can load it without + // needing deeper traversal into each Finding. + let root: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(root.get("task_prose").and_then(|v| v.as_array()).is_some()); + + std::fs::remove_dir_all(&dir).ok(); + } + #[tokio::test] async fn first_apply_writes_canonical_file() { let dir = tmp_dir("create"); diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 8626978..3dae3d8 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -1068,6 +1068,44 @@ impl Session { } } } + // Provenance stamp written into findings' + // first_seen_task / last_updated_task. Shape: + // "/" when a todo + // dispatched this task (cmd_next / + // cmd_continue paths), else just the uuid. + // We avoid the prior `r.name` convention — + // for an operator-typed `/review …` task + // `r.name` is the full prompt body, which + // got duplicated across every finding. + let stamp = match r.todo_name.as_deref() { + Some(tag) => format!("{}/{}", r.uuid.as_simple(), tag), + None => r.uuid.as_simple().to_string(), + }; + // Persist the task's effective_analysis at the + // file level for `/summary`'s benefit, regardless + // of whether a finding delta landed. Captures the + // broader-than-finding narrative (overview, + // summary tables, cross-cutting conclusions, + // multi-step proofs) that no single + // `Finding.details[].analysis` claims ownership + // of — observed missing from session + // `kres-findings2`, where 21 `###` headings in + // report.md had no counterpart in any finding's + // JSON body. NEVER forwarded to an agent: + // `task_prose` sits on `FindingsFile`, and agents + // only see `&[Finding]` via + // `redact_findings_for_agent`. + if !effective_analysis.is_empty() { + if let Some(ref s) = store_for_reaper { + if let Err(e) = + s.append_task_prose(&stamp, &effective_analysis).await + { + kres_core::async_eprintln!( + "task_prose append: {e}" + ); + } + } + } let had_delta = r.mode.produces_findings() && !working_delta.is_empty(); let mut apply_changed = false; let mut apply_added: u32 = 0; @@ -1076,19 +1114,6 @@ impl Session { let mut apply_reactivated: u32 = 0; if had_delta { let delta = working_delta.clone(); - // Provenance stamp written into findings' - // first_seen_task / last_updated_task. Shape: - // "/" when a todo - // dispatched this task (cmd_next / - // cmd_continue paths), else just the uuid. - // We avoid the prior `r.name` convention — - // for an operator-typed `/review …` task - // `r.name` is the full prompt body, which - // got duplicated across every finding. - let stamp = match r.todo_name.as_deref() { - Some(tag) => format!("{}/{}", r.uuid.as_simple(), tag), - None => r.uuid.as_simple().to_string(), - }; // effective_analysis is the prose we want on // every finding this task touched, stored // under `details` for /summary to consume From 7668b697b355efd355cca2b03a4a0eca099e52c3 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 11:46:23 -0700 Subject: [PATCH 47/76] embedded_prompts: translate legacy basename to post-rename key Operator configs installed before the Audit rename still point their slow-agent system_file at "system-prompts/slow-code-agent.system.md". The rename embedded the file under "slow-code-agent-audit.system.md", so fresh binaries error out on those old configs with "no embedded fallback for basename slow-code-agent.system.md". Translate the legacy basename to the current one inside embedded_prompts::lookup via a small match. One-way, opt-in per entry, clearly attributed to the rename so a future cleanup can find and remove it. Signed-off-by: Chris Mason --- kres-agents/src/embedded_prompts.rs | 51 +++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs index 3e77375..18f9f36 100644 --- a/kres-agents/src/embedded_prompts.rs +++ b/kres-agents/src/embedded_prompts.rs @@ -52,13 +52,37 @@ const TABLE: &[(&str, &str)] = &[ ), ]; +/// Translate legacy prompt-file basenames to their post-rename +/// equivalents. Operator configs installed from an older repo keep +/// the old `system_file` path; applying this shim on lookup lets +/// those configs resolve against the new embedded table without +/// the operator having to re-run setup.sh. +/// +/// Add new entries here each time a `configs/prompts/*.system.md` +/// file gets renamed; remove them when the old basename has had +/// enough deprecation time. Each entry is a deliberate one-way +/// translation — the key is what operators might still emit, the +/// value is what the embedded TABLE keys on now. +fn translate_legacy_basename(basename: &str) -> &str { + match basename { + // b01c1ae (Analysis → Audit): defect-review system prompt + // file renamed from slow-code-agent.system.md to + // slow-code-agent-audit.system.md. + "slow-code-agent.system.md" => "slow-code-agent-audit.system.md", + other => other, + } +} + /// Return the embedded prompt body for a filename's basename, if /// one is bundled in this build. `basename` is the final path /// component with any directory prefix stripped (e.g. /// `"main-agent.system.md"` for a config field -/// `"prompts/main-agent.system.md"`). +/// `"prompts/main-agent.system.md"`). Legacy basenames from +/// pre-rename installs are translated to the current key via +/// [`translate_legacy_basename`] before the table lookup. pub fn lookup(basename: &str) -> Option<&'static str> { - TABLE.iter().find(|(k, _)| *k == basename).map(|(_, v)| *v) + let key = translate_legacy_basename(basename); + TABLE.iter().find(|(k, _)| *k == key).map(|(_, v)| *v) } /// Every basename that has an embedded copy. Useful for logging / @@ -92,6 +116,29 @@ mod tests { assert!(lookup("main-agent.system.md").is_some()); } + #[test] + fn legacy_slow_code_agent_basename_translates_to_audit() { + // Operator configs installed before b01c1ae point at + // `slow-code-agent.system.md`; the embedded table now keys + // on `slow-code-agent-audit.system.md`. The translation + // shim must resolve the old basename to the new prompt + // body WITHOUT the operator needing to edit their config + // or re-run setup.sh. + let legacy = lookup("slow-code-agent.system.md") + .expect("legacy basename must resolve via translation"); + let new = lookup("slow-code-agent-audit.system.md") + .expect("new basename must resolve directly"); + assert_eq!(legacy, new, "translation must return identical body"); + } + + #[test] + fn translate_legacy_passes_through_unknown_basenames() { + // Non-legacy basenames must not be rewritten — the shim is + // opt-in per entry. + assert_eq!(translate_legacy_basename("todo-agent.system.md"), "todo-agent.system.md"); + assert_eq!(translate_legacy_basename("does-not-exist.md"), "does-not-exist.md"); + } + #[test] fn all_expected_agent_prompts_are_present() { for expected in [ From d9623db9edc9c87c25f2b9d8afb6ffdb56df936b Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Thu, 23 Apr 2026 13:09:03 -0700 Subject: [PATCH 48/76] summary: derive from findings.json alone /summary, /summary-markdown, --summary, and --summary-markdown now build their bug reports without consulting report.md. The summariser opens findings.json via jsondb, sorts active findings by severity, buckets the per-task material (finding.details[] plus task_prose[]), runs one fast-agent "condense" call per task to extract observations, then renders the result through the existing bug-summary template in batches that fit the fast agent's context window. Signed-off-by: Chris Mason --- configs/prompts/bug-summary-markdown.md | 18 +- configs/prompts/bug-summary.md | 18 +- configs/prompts/condense-task.system.md | 53 ++ kres-agents/src/embedded_prompts.rs | 5 + kres-repl/src/session.rs | 31 +- kres-repl/src/summary.rs | 986 ++++++++++++++++++------ kres/src/main.rs | 39 +- 7 files changed, 878 insertions(+), 272 deletions(-) create mode 100644 configs/prompts/condense-task.system.md diff --git a/configs/prompts/bug-summary-markdown.md b/configs/prompts/bug-summary-markdown.md index ca49b65..ba72d2b 100644 --- a/configs/prompts/bug-summary-markdown.md +++ b/configs/prompts/bug-summary-markdown.md @@ -15,10 +15,14 @@ LINE ACROSS TWO LINES. Produce a bug report about existing code based on this template. The inputs describe a research run: an optional original_prompt (the -top-level question that drove the run), a report.md narrative, a -findings.json with structured bug entries, and any extra context the run -accumulated. Your job is to turn those inputs into a single markdown -bug report covering every bug that was found. +top-level question that drove the run), a findings list sorted by +severity (most severe first), and a task_observations string — a +condensed, already-merged block of observations drawn from every +analysis task that contributed to a finding. Your job is to turn +those inputs into a single markdown bug report covering every bug +that was found. Treat the task_observations text as supporting +detail to fold into the relevant bug's section — quote from it when +it sharpens the question, do not attribute observations to tasks. - If original_prompt is non-empty, open the report with one or two sentences of context that restates what the run was looking into, @@ -87,9 +91,9 @@ fold that material into the prose without repeating it verbatim. - If multiple findings describe the same underlying bug, merge them into one section and cite every affected code site. -- Do not invent facts. If the findings.json and report.md do not -support a claim, do not make it. If a finding lacks the detail you -want to cite, drop that detail rather than guess. +- Do not invent facts. If the findings list and task_observations +do not support a claim, do not make it. If a finding lacks the +detail you want to cite, drop that detail rather than guess. ## Ensure clear, concise paragraphs diff --git a/configs/prompts/bug-summary.md b/configs/prompts/bug-summary.md index 9adb3a8..f6dfe52 100644 --- a/configs/prompts/bug-summary.md +++ b/configs/prompts/bug-summary.md @@ -15,10 +15,14 @@ LINE ACROSS TWO LINES. Produce a bug report about existing code based on this template. The inputs describe a research run: an optional original_prompt (the -top-level question that drove the run), a report.md narrative, a -findings.json with structured bug entries, and any extra context the run -accumulated. Your job is to turn those inputs into a single, plain-text -bug report covering every bug that was found. +top-level question that drove the run), a findings list sorted by +severity (most severe first), and a task_observations string — a +condensed, already-merged block of observations drawn from every +analysis task that contributed to a finding. Your job is to turn +those inputs into a single, plain-text bug report covering every +bug that was found. Treat the task_observations text as supporting +detail to fold into the relevant bug's section — quote from it when +it sharpens the question, do not attribute observations to tasks. - If original_prompt is non-empty, open the report with one or two sentences of plain-text context that restates what the run was looking @@ -84,9 +88,9 @@ material into the prose without repeating it verbatim. - If multiple findings describe the same underlying bug, merge them into one section and cite every affected code site. -- Do not invent facts. If the findings.json and report.md do not support -a claim, do not make it. If a finding lacks the detail you want to cite, -drop that detail rather than guess. +- Do not invent facts. If the findings list and task_observations +do not support a claim, do not make it. If a finding lacks the +detail you want to cite, drop that detail rather than guess. ## Ensure clear, concise paragraphs diff --git a/configs/prompts/condense-task.system.md b/configs/prompts/condense-task.system.md new file mode 100644 index 0000000..046bdfe --- /dev/null +++ b/configs/prompts/condense-task.system.md @@ -0,0 +1,53 @@ +You are condensing prose that multiple analysis tasks produced +during a kernel code review run. The caller batches several tasks +into one request so you distill them together in a single pass. + +The caller is building ONE aggregate bug report. You are NOT +preserving per-task structure, attribution, or ordering — your job +is to emit a tight block of observations the downstream writer can +quote from when assembling the final report. + +Input shape: + + {"task": "condense_tasks", + "items": [ + {"task_id": "", + "findings_touched": [ + {"id": "...", "title": "...", "analysis": ""}, + ... + ], + "task_prose": ""}, + ... + ]} + +Output shape: PLAIN TEXT, no JSON, no fences, no preamble. Just the +observations. + +Distillation rules: + +- Merge overlapping observations across items. If three tasks all + noted the same race, write it once. +- Group related observations around the finding id they pertain to + — use `Finding :` on its own line as a mini-heading when a + block of paragraphs covers one finding. Use `General:` for + observations that aren't tied to a specific finding. +- Quote code exactly as it appeared in the input. Do not reformat + or invent line numbers. +- Keep every technical claim that names a function, file, race + window, invariant, call chain, or code snippet. Drop + conversational filler, self-reference, task bookkeeping, and + anything already carried by the finding's own `summary` / + `mechanism_detail` / `reproducer_sketch` fields. +- Never invent facts. If the input doesn't support a claim, drop + it. +- 72-character wrap on every prose line. The only lines allowed to + exceed 72 are verbatim code quoted from the input. +- No markdown fences, no bullet markers (`-`, `*`), no headings + other than the `Finding :` / `General:` markers above. Plain + prose with blank lines between paragraphs. +- Do not address the caller. No "the tasks found ...", no "below + are the observations". Just the observations. +- Terse wins. If the batch is mostly redundancy, emit a short + document. Do not pad. + +End the output with a single trailing newline. diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs index 18f9f36..8c24a65 100644 --- a/kres-agents/src/embedded_prompts.rs +++ b/kres-agents/src/embedded_prompts.rs @@ -50,6 +50,10 @@ const TABLE: &[(&str, &str)] = &[ "todo-agent.system.md", include_str!("../../configs/prompts/todo-agent.system.md"), ), + ( + "condense-task.system.md", + include_str!("../../configs/prompts/condense-task.system.md"), + ), ]; /// Translate legacy prompt-file basenames to their post-rename @@ -148,6 +152,7 @@ mod tests { "slow-code-agent-coding.system.md", "slow-code-agent-generic.system.md", "todo-agent.system.md", + "condense-task.system.md", ] { assert!( lookup(expected).is_some(), diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 3dae3d8..291326d 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -2737,12 +2737,17 @@ impl Session { } } - /// `/summary` — render the run's report.md + findings.json into - /// a plain-text summary via the fast agent using the `summary` + /// `/summary` — render the run's findings.json into a plain-text + /// bug report via the fast agent using the `summary` /// slash-command template. Pass `markdown=true` (via /// `/summary-markdown`) to select the markdown-variant template /// and default the output filename to `summary.md` instead of /// `summary.txt`. + /// + /// report.md is NOT consulted. The summariser reads findings.json + /// via jsondb, runs a per-task condense pass, then renders the + /// result through the bug-summary template in batches that fit + /// the fast agent's context window. async fn cmd_summary(&self, filename: Option, markdown: bool) { let Some(orc) = self.orchestrator.as_ref() else { async_println( @@ -2750,28 +2755,28 @@ impl Session { ); return; }; - let Some(report_path) = self.cfg.report_path.clone() else { - async_println("/summary: no report path configured"); + let Some(findings_path) = self.cfg.findings_base.clone() else { + async_println("/summary: no findings path configured"); return; }; - if !report_path.exists() { + if !findings_path.exists() { async_println(format!( "/summary: {} does not exist yet — run at least one task", - report_path.display() + findings_path.display() )); return; } // Output goes to the explicit --results dir when the operator - // set one (so prompt.md, findings.json, report.md, and - // summary.txt all live together). Without --results, fall - // back to the report.md's parent — that's still inside the - // defaulted ~/.kres/sessions// tree, just not flagged as - // operator-chosen. + // set one (so prompt.md, findings.json, and summary.txt all + // live together). Without --results, fall back to the + // findings.json's parent — that's still inside the defaulted + // ~/.kres/sessions// tree, just not flagged as operator- + // chosen. let output_dir = self .cfg .results_dir .clone() - .or_else(|| report_path.parent().map(std::path::Path::to_path_buf)); + .or_else(|| findings_path.parent().map(std::path::Path::to_path_buf)); // /summary-markdown defaults the filename to summary.md // instead of summary.txt; --summary-markdown at the CLI // behaves the same way. @@ -2783,7 +2788,6 @@ impl Session { let effective_name = filename.as_deref().or(default_name); let output_path = crate::summary::default_output_path(output_dir.as_deref(), effective_name); - let findings_path = self.cfg.findings_base.clone(); // Original prompt resolution: in-memory initial_prompt wins // (it's the literal --prompt FILE or first submission). If // that's empty, look for prompt.md in the results dir; the @@ -2803,7 +2807,6 @@ impl Session { }), }; let inputs = crate::summary::SummaryInputs { - report_path, findings_path, output_path: output_path.clone(), template_path: self.cfg.template_path.clone(), diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index da218d9..8031c3c 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -1,31 +1,53 @@ -//! /summary and `kres --summary` — render a plain-text summary from -//! a research run's report.md + findings.json. +//! /summary and `kres --summary` — render a plain-text (or markdown) +//! bug report from `findings.json` ALONE. report.md is no longer +//! consulted by this module; every fact the summary emits comes from +//! the findings store. //! -//! The summariser is backed by the `/summary` (or -//! `/summary-markdown`) slash-command template. The binary carries -//! the embedded default via `kres_agents::user_commands`, and an -//! operator can shadow it by dropping a file under -//! `~/.kres/commands/`. Resolution order inside `run_summary`: -//! 1. `inputs.template_path` (explicit `--template FILE`), -//! 2. `user_commands::lookup("summary")` / -//! `user_commands::lookup("summary-markdown")` — which -//! itself prefers `~/.kres/commands/.md` on disk and -//! falls back to the compiled-in default. +//! Flow: +//! 1. Open the findings file via `kres_core::FindingsStore` (jsondb) +//! and take a `FindingsFile` snapshot. +//! 2. Filter out `Status::Invalidated` and sort the remaining +//! findings by severity, most severe first; within one severity +//! keep the store's insertion order. +//! 3. Bucket the per-task material: for every task id that appears +//! in `finding.details[].task` ∪ `task_prose[].task`, collect +//! the finding-by-finding analysis snippets and the file-level +//! task_prose body. This is the set of "per-task summaries and +//! details" the user asked for. +//! 4. Condense pass: greedy-pack tasks into batches that each fit +//! the fast-agent input budget, then issue ONE call per batch +//! using the embedded `condense-task.system.md` system prompt. +//! The output is plain prose — since the final document is one +//! aggregate report, we don't need per-task keying in the +//! condense result. Batch outputs are concatenated into a +//! single `task_observations` string the render pass quotes +//! from. A single task that alone exceeds the budget falls +//! back to `condense_single_task`, which recursively splits +//! per_finding halves and drops task_prose with a breadcrumb. +//! 5. Render pass: send the sorted findings (with `details` +//! stripped via `redact_findings_for_agent`) plus the +//! `task_observations` string to the `summary` (or +//! `summary-markdown`) slash-command template. Single-shot +//! when the prompt fits `max_input_tokens`; otherwise split +//! findings into batches that each fit (every batch carries +//! the full observations string), render one partial per +//! batch, then combine the partials. //! -//! Stale files under `~/.kres/prompts/` or -//! `~/.kres/system-prompts/` are never consulted from this -//! module — `~/.kres/commands/` is the canonical override path -//! for slash-command templates. +//! The `/summary` / `/summary-markdown` commands and the CLI flags +//! `--summary` / `--summary-markdown` all land here — only the +//! template choice and output filename differ between them. +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; -use kres_core::findings::Finding; +use kres_core::findings::{ + redact_findings_for_agent, Finding, FindingsFile, FindingsStore, Severity, Status, +}; use serde_json::json; use kres_agents::AgentConfig; -use kres_core::findings::FindingsFile; use kres_llm::{client::Client, config::CallConfig, request::Message, Model}; /// Conservative fallback when the caller didn't set max_input_tokens @@ -51,19 +73,21 @@ pub fn default_markdown_template_path() -> Option { /// All the inputs to one summary run. Constructed once by either the /// REPL command handler or the `kres --summary` main-entry path. pub struct SummaryInputs { - pub report_path: PathBuf, - pub findings_path: Option, + /// Path to `findings.json`. Required — the summary is derived + /// from this file alone. + pub findings_path: PathBuf, pub output_path: PathBuf, - /// Explicit override for the system prompt template. When Some, - /// run_summary reads the file and errors if it cannot. When None, - /// `~/.kres/commands/summary.md` wins if it exists; else the - /// compiled-in `summary` body from `kres_agents::user_commands` - /// is used. When `markdown` is true the `summary-markdown` - /// variant is selected at each hop instead. + /// Explicit override for the render-pass system prompt template. + /// When Some, run_summary reads the file and errors if it cannot. + /// When None, `~/.kres/commands/summary.md` wins if it exists; + /// else the compiled-in `summary` body from + /// `kres_agents::user_commands` is used. When `markdown` is true + /// the `summary-markdown` variant is selected at each hop + /// instead. pub template_path: Option, - /// Select the markdown variant of the template + the `.md` output - /// filename default. Ignored when `template_path` is set (the - /// caller has already chosen a template). + /// Select the markdown variant of the template + the `.md` + /// output filename default. Ignored when `template_path` is set + /// (the caller has already chosen a template). pub markdown: bool, /// The top-level question that drove this research run. Loaded /// from in-REPL memory or `/prompt.md`. When absent we @@ -89,10 +113,10 @@ pub fn default_output_path(results_dir: Option<&Path>, filename: Option<&str>) - } /// Build a minimal fast-agent LLM client from a fast-code-agent config -/// file. `kres --summary` uses this so it can issue the one-shot -/// summary call without spinning up the full orchestrator. The -/// summariser is cheap and short — the fast agent is plenty strong -/// for it, and we avoid burning slow-agent budget on formatting work. +/// file. `kres --summary` uses this so it can issue the summary calls +/// without spinning up the full orchestrator. The summariser runs on +/// the fast agent — per-task condensation and per-batch rendering are +/// both formatting work that the slow agent would be overkill for. pub fn load_fast_for_summary( fast_cfg_path: &Path, settings: &crate::settings::Settings, @@ -109,7 +133,7 @@ pub fn load_fast_for_summary( Ok((client, fast_model, max_tokens, fast_cfg.max_input_tokens)) } -/// Resolve the summariser's system-prompt template to a +/// Resolve the render-pass system prompt template to a /// (source-label, body) pair. Each disk path is read at most once; /// the embedded fallback skips disk entirely. Precedence: /// 1. `inputs.template_path` (explicit `--template FILE`). @@ -143,114 +167,160 @@ fn resolve_template(inputs: &SummaryInputs) -> Result<(String, String)> { Ok((fallback_label.to_string(), body)) } -/// Run the summary pipeline. Reads report.md (required) and -/// findings.json (optional — missing is a warning, not an error), -/// sends them to the fast agent with the embedded template as the -/// system prompt, and writes the response to `inputs.output_path`. -/// -/// When the assembled prompt exceeds `max_input_tokens` (or the -/// conservative [`DEFAULT_INPUT_BUDGET`] fallback), the run switches -/// to a map-reduce shape: findings are split into chunks that each -/// fit, the template is applied to each chunk to produce a partial -/// summary, and a final combine call merges the partials into one -/// output. The single-call path stays the default when the payload -/// fits. +/// Material attributed to one task id: the per-finding analysis +/// snippets that task contributed, plus any file-level +/// [`TaskProse`](kres_core::findings::TaskProse) body it emitted. +/// Assembled from the `FindingsFile` before the condense pass. +#[derive(Debug, Default, Clone)] +struct TaskMaterial { + /// `(finding_id, finding_title, per-task analysis body)`, in + /// findings-array order. + per_finding: Vec<(String, String, String)>, + /// The `task_prose[].prose` body for this task, or empty when + /// the task never emitted file-level narrative. + prose: String, +} + +/// Render the summary. Reads findings.json, runs the condense + render +/// passes, writes the output file. pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { - let report_md = std::fs::read_to_string(&inputs.report_path) - .with_context(|| format!("reading report {}", inputs.report_path.display()))?; - if report_md.trim().is_empty() { + if !inputs.findings_path.exists() { return Err(anyhow!( - "report {} is empty — nothing to summarise", - inputs.report_path.display() + "findings file {} does not exist — nothing to summarise", + inputs.findings_path.display() )); } - let (findings, findings_note) = match &inputs.findings_path { - Some(p) if p.exists() => { - let raw = std::fs::read_to_string(p) - .with_context(|| format!("reading findings {}", p.display()))?; - let file: FindingsFile = serde_json::from_str(&raw) - .with_context(|| format!("parsing findings {}", p.display()))?; - (file.findings, String::new()) - } - Some(p) => { - let msg = format!( - "warning: findings file {} does not exist; producing report from report.md only", - p.display() - ); - eprintln!("{msg}"); - (Vec::new(), msg) - } - None => { - let msg = "warning: no findings file supplied; producing report from report.md only" - .to_string(); - eprintln!("{msg}"); - (Vec::new(), msg) - } - }; + // 1. Load via jsondb. `FindingsStore::new` opens the same + // canonical on-disk schema the pipeline writes; `file_snapshot` + // returns a Clone that detaches us from the live + // lock so the summariser doesn't hold a read guard across the + // LLM round-trips. + let store = FindingsStore::new(inputs.findings_path.clone()) + .await + .with_context(|| format!("opening findings {}", inputs.findings_path.display()))?; + let file: FindingsFile = store.file_snapshot().await; + + // 2. Filter invalidated + sort by severity (descending), preserve + // insertion order within a severity band. `Vec::sort_by` is + // stable in the std lib, so equal-severity findings keep the + // relative order they appear in on disk. + let mut active: Vec = file + .findings + .iter() + .filter(|f| f.status != Status::Invalidated) + .cloned() + .collect(); + active.sort_by(|a, b| severity_rank(b.severity).cmp(&severity_rank(a.severity))); + + eprintln!( + "summary: {} active finding(s) (filtered {} invalidated), {} task_prose entry(s)", + active.len(), + file.findings.len() - active.len(), + file.task_prose.len(), + ); + + if active.is_empty() && file.task_prose.is_empty() { + return Err(anyhow!( + "no active findings and no task_prose in {} — nothing to summarise", + inputs.findings_path.display() + )); + } + + // 3. Bucket per-task material. Order tasks by first appearance + // so the condense calls and logs stay stable across runs on the + // same input. + let (task_order, mut tasks) = bucket_task_material(&active, &file); + eprintln!( + "summary: {} distinct task id(s) contributing material", + task_order.len() + ); + + // 4. Condense pass. Tasks are packed into batches that each fit + // the fast agent's input budget — one API call per batch, not + // one per task. A run with 37 tasks collapses to ~2-3 calls + // instead of 37. Per-task overflow (a single task too big to + // batch with anything else) falls through to the single-task + // split+drop fallback in `condense_single_task`. + let condense_system = kres_agents::embedded_prompts::lookup("condense-task.system.md") + .ok_or_else(|| anyhow!("condense-task.system.md missing from embedded table — build bug"))? + .to_string(); + let mut condense_cfg = CallConfig::defaults_for(inputs.model.clone()) + .with_max_tokens(inputs.max_tokens) + .with_stream_label("summary condense") + .with_system(condense_system); + if let Some(n) = inputs.max_input_tokens { + condense_cfg = condense_cfg.with_max_input_tokens(n); + } - // Resolve the system prompt template: explicit --template wins, - // else the on-disk operator override under ~/.kres/commands/, - // else the compiled-in default. `inputs.markdown` (from the - // `/summary-markdown` command or the `--summary-markdown` CLI - // flag) picks the markdown variant at each hop. We read each - // file at most once — the per-hop log line names the source so - // operators can tell which template actually shaped the output. + let budget = inputs.max_input_tokens.unwrap_or(DEFAULT_INPUT_BUDGET); + + let task_observations: String = condense_tasks_batched( + &inputs.client, + &condense_cfg, + &task_order, + &mut tasks, + budget, + ) + .await?; + + // 5. Render pass. Resolve the template once; reuse it for the + // single-shot attempt and any partial renders below. let (template_src, template_text) = resolve_template(&inputs)?; eprintln!("summary: template = {}", template_src); - let mut cfg = CallConfig::defaults_for(inputs.model.clone()) + let mut render_cfg = CallConfig::defaults_for(inputs.model.clone()) .with_max_tokens(inputs.max_tokens) - .with_stream_label("summary"); - cfg = cfg.with_system(template_text.clone()); + .with_stream_label("summary render") + .with_system(template_text.clone()); if let Some(n) = inputs.max_input_tokens { - cfg = cfg.with_max_input_tokens(n); + render_cfg = render_cfg.with_max_input_tokens(n); } - let budget = inputs.max_input_tokens.unwrap_or(DEFAULT_INPUT_BUDGET); let original_prompt = inputs.original_prompt.as_deref().unwrap_or(""); - let findings_note_opt = if findings_note.is_empty() { - None - } else { - Some(findings_note.as_str()) - }; - // One-shot attempt first: build the full prompt and see if it - // fits the budget. `count_tokens_exact` returns None on API - // failure — fall back to a chars/4 heuristic rather than - // assuming either direction. - let full_prompt = build_prompt_json(original_prompt, &report_md, &findings, findings_note_opt)?; + // Redact findings for the render (strip `details[]` — that's + // what the condense pass consumed; the render pass sees the + // condensed observations via `task_observations`). + let render_findings = redact_findings_for_agent(&active); + + // One-shot attempt first. `size_call` short-circuits the exact + // count when the chars/4 estimate is comfortably under budget. + // The observations block is typically small relative to + // findings bodies — we always send it whole alongside every + // render call (single-shot and each partial). + let full_prompt = build_render_prompt( + original_prompt, + &render_findings, + &task_observations, + None, + )?; let full_messages = vec![user_message(&full_prompt)]; - let size = size_call(&inputs.client, &cfg, &full_messages, budget).await; + let size = size_call(&inputs.client, &render_cfg, &full_messages, budget).await; eprintln!( - "summary: input sizing findings={} report_chars={} tokens={:?} budget={}", - findings.len(), - report_md.len(), + "summary: render sizing findings={} observations_chars={} tokens={:?} budget={}", + render_findings.len(), + task_observations.len(), size, - budget + budget, ); let needs_staging = size.map(|t| t > budget as u64).unwrap_or(false); let text = if !needs_staging { eprintln!( - "summary: single-shot to {} ({} finding(s), original_prompt={})", + "summary: single-shot render to {} ({} finding(s), original_prompt={})", inputs.model.id, - findings.len(), - if original_prompt.is_empty() { - "no" - } else { - "yes" - }, + render_findings.len(), + if original_prompt.is_empty() { "no" } else { "yes" }, ); - call_and_extract(&inputs.client, &cfg, &full_messages, "summary").await? + call_and_extract(&inputs.client, &render_cfg, &full_messages, "summary render").await? } else { - stage_summary( + stage_render( &inputs, - &cfg, + &render_cfg, original_prompt, - &report_md, - &findings, - findings_note_opt, + &render_findings, + &task_observations, budget, ) .await? @@ -276,84 +346,356 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { Ok(()) } -/// Map-reduce path: chunk findings into groups that each fit the -/// input budget (with the same report.md + template attached), call -/// the fast agent on each, then combine the partials into one final -/// output. Triggered by `run_summary` when the single-shot prompt -/// oversizes. -#[allow(clippy::too_many_arguments)] -async fn stage_summary( +/// Walk the findings and task_prose array to build: +/// - an ordered list of task ids, first-appearance order across +/// both lists (findings first, then any task_prose-only tasks); +/// - a map of task_id → TaskMaterial. +fn bucket_task_material( + findings: &[Finding], + file: &FindingsFile, +) -> (Vec, BTreeMap) { + let mut order: Vec = Vec::new(); + let mut seen: BTreeSet = BTreeSet::new(); + let mut out: BTreeMap = BTreeMap::new(); + + for f in findings { + for d in &f.details { + if d.task.is_empty() || d.analysis.trim().is_empty() { + continue; + } + if seen.insert(d.task.clone()) { + order.push(d.task.clone()); + } + out.entry(d.task.clone()) + .or_default() + .per_finding + .push((f.id.clone(), f.title.clone(), d.analysis.clone())); + } + } + + for p in &file.task_prose { + if p.task.is_empty() || p.prose.trim().is_empty() { + continue; + } + if seen.insert(p.task.clone()) { + order.push(p.task.clone()); + } + let slot = out.entry(p.task.clone()).or_default(); + // Tolerate the rare case where a single task emitted more + // than one task_prose entry (append with a blank line between + // bodies so the condenser sees both). + if !slot.prose.is_empty() { + slot.prose.push_str("\n\n"); + } + slot.prose.push_str(&p.prose); + } + + (order, out) +} + +/// Greedy-pack tasks into batches that each fit the input budget; +/// one API call per batch. Each batch returns a plain-text block of +/// observations — no per-task keying, no JSON envelope. The blocks +/// are concatenated into the single observations string the render +/// pass quotes from. +/// +/// Batching: +/// - Walk `task_order` and accumulate items into `pending`. +/// - After each add, `size_call` the pending batch. When the +/// estimate crosses `budget`, flush the batch WITHOUT the item +/// that pushed it over, then seed a new batch with that item. +/// - If a single item alone exceeds `budget`, hand it to +/// `condense_single_task` for the per-task split/drop fallback. +/// +/// `tasks` is consumed as we go (`.remove()` on each key). +async fn condense_tasks_batched( + client: &Client, + cfg: &CallConfig, + task_order: &[String], + tasks: &mut BTreeMap, + budget: u32, +) -> Result { + let mut blocks: Vec = Vec::new(); + let mut pending: Vec<(String, TaskMaterial)> = Vec::new(); + let mut batch_n: usize = 0; + + for (idx, task_id) in task_order.iter().enumerate() { + let material = tasks.remove(task_id).unwrap_or_default(); + eprintln!( + "summary: packing task {}/{} id={} findings={} prose_chars={}", + idx + 1, + task_order.len(), + truncate(task_id, 40), + material.per_finding.len(), + material.prose.len(), + ); + + // Probe with the candidate added. + pending.push((task_id.clone(), material)); + let prompt = build_batch_condense_prompt(&pending)?; + let messages = vec![user_message(&prompt)]; + let size = size_call(client, cfg, &messages, budget).await; + let fits = size.map(|t| t <= budget as u64).unwrap_or(true); + if fits { + continue; + } + + // Oversize. Pop the offender, flush what was there before. + let (offender_id, offender_material) = pending.pop().expect("just pushed"); + if !pending.is_empty() { + batch_n += 1; + let block = flush_batch(client, cfg, &pending, batch_n).await?; + blocks.push(block); + pending.clear(); + } + + // At this point pending is empty. Probe the offender alone + // BEFORE reseeding the batch — if the offender on its own + // exceeds the budget we must NOT ship it through + // flush_batch (that would hit the API with an over-budget + // prompt and bounce). Route to condense_single_task + // instead, which splits/drops the material until it fits. + let probe_one = vec![(offender_id.clone(), offender_material.clone())]; + let probe_prompt = build_batch_condense_prompt(&probe_one)?; + let probe_msgs = vec![user_message(&probe_prompt)]; + let probe_size = size_call(client, cfg, &probe_msgs, budget).await; + let probe_fits = probe_size.map(|t| t <= budget as u64).unwrap_or(true); + if probe_fits { + pending.push((offender_id, offender_material)); + continue; + } + + eprintln!( + "summary: task {} alone exceeds budget; falling back to single-task split", + truncate(&offender_id, 40), + ); + let single_label = format!("summary condense single {}", truncate(&offender_id, 40)); + let block = condense_single_task( + client, + cfg, + &offender_id, + &offender_material, + &single_label, + budget, + ) + .await?; + blocks.push(block); + } + + if !pending.is_empty() { + batch_n += 1; + let block = flush_batch(client, cfg, &pending, batch_n).await?; + blocks.push(block); + } + + eprintln!( + "summary: condense produced {} block(s) across {} batch call(s)", + blocks.len(), + batch_n, + ); + Ok(join_blocks(&blocks)) +} + +/// Concatenate batch condensation blocks into a single +/// observations string, separated by blank lines. Trailing +/// whitespace on each block is normalised so blocks don't +/// compound-stack blank lines. +fn join_blocks(blocks: &[String]) -> String { + let mut out = String::new(); + for b in blocks { + let trimmed = b.trim_end_matches(|c: char| c == '\n' || c.is_whitespace()); + if trimmed.is_empty() { + continue; + } + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(trimmed); + } + if !out.is_empty() { + out.push('\n'); + } + out +} + +/// Fire one batch-condense call and return its prose output. +/// `batch_n` is the 1-based batch index used for the stream label. +async fn flush_batch( + client: &Client, + cfg: &CallConfig, + batch: &[(String, TaskMaterial)], + batch_n: usize, +) -> Result { + let prompt = build_batch_condense_prompt(batch)?; + let messages = vec![user_message(&prompt)]; + let label = format!("summary condense batch {batch_n}"); + eprintln!( + "summary: condense batch {} — {} task(s)", + batch_n, + batch.len() + ); + call_and_extract(client, cfg, &messages, &label).await +} + +/// Single-task fallback: used only when a task on its own is too +/// big to fit in a batch. Recursively splits `per_finding` and +/// drops `task_prose` with a breadcrumb, reusing the batch prompt +/// shape with a one-item list. +async fn condense_single_task( + client: &Client, + cfg: &CallConfig, + task_id: &str, + material: &TaskMaterial, + label: &str, + budget: u32, +) -> Result { + let pending: Vec<(String, TaskMaterial)> = vec![(task_id.to_string(), material.clone())]; + let prompt = build_batch_condense_prompt(&pending)?; + let messages = vec![user_message(&prompt)]; + let size = size_call(client, cfg, &messages, budget).await; + let fits = size.map(|t| t <= budget as u64).unwrap_or(true); + if fits { + return call_and_extract(client, cfg, &messages, label).await; + } + eprintln!( + "summary: single-task condense oversize for {} (budget={}); splitting", + truncate(task_id, 40), + budget, + ); + + // Split per_finding in half; first half keeps the prose. + if material.per_finding.len() >= 2 { + let mid = material.per_finding.len() / 2; + let (left, right) = material.per_finding.split_at(mid); + let first = TaskMaterial { + per_finding: left.to_vec(), + prose: material.prose.clone(), + }; + let second = TaskMaterial { + per_finding: right.to_vec(), + prose: String::new(), + }; + let l1 = format!("{label} 1/2"); + let l2 = format!("{label} 2/2"); + let a = Box::pin(condense_single_task(client, cfg, task_id, &first, &l1, budget)).await?; + let b = Box::pin(condense_single_task(client, cfg, task_id, &second, &l2, budget)).await?; + let mut joined = a; + if !joined.ends_with('\n') { + joined.push('\n'); + } + joined.push('\n'); + joined.push_str(&b); + return Ok(joined); + } + + // One (or zero) per_finding entry left. Prose is the overflow. + if !material.prose.is_empty() { + let stripped = TaskMaterial { + per_finding: material.per_finding.clone(), + prose: String::new(), + }; + let stripped_pending: Vec<(String, TaskMaterial)> = + vec![(task_id.to_string(), stripped)]; + let stripped_prompt = build_batch_condense_prompt(&stripped_pending)?; + let stripped_messages = vec![user_message(&stripped_prompt)]; + let stripped_size = size_call(client, cfg, &stripped_messages, budget).await; + if stripped_size.map(|t| t <= budget as u64).unwrap_or(true) { + eprintln!( + "summary: condense dropping task_prose ({} chars) for {} to fit budget", + material.prose.len(), + truncate(task_id, 40), + ); + let body = call_and_extract(client, cfg, &stripped_messages, label).await?; + let mut out = body; + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str( + "\n[DROPPED task_prose] The file-level narrative for this task \ + exceeded the condense input budget and was elided.\n", + ); + return Ok(out); + } + } + + Err(anyhow!( + "condense call for task {} exceeds the {} input-token budget even with \ + a single finding and no task_prose — raise max_input_tokens or \ + shrink the finding.details[] analysis body", + truncate(task_id, 60), + budget, + )) +} + +/// Map-reduce render path: split findings into batches that fit the +/// input budget, call the render template on each with the full +/// `task_observations` string, then combine the partials. The +/// observations string is small relative to findings bodies and +/// already condensed, so it always rides along in full — batching +/// happens only on the findings axis. +async fn stage_render( inputs: &SummaryInputs, cfg: &CallConfig, original_prompt: &str, - report_md: &str, findings: &[Finding], - findings_note_opt: Option<&str>, + task_observations: &str, budget: u32, ) -> Result { if findings.is_empty() { return Err(anyhow!( - "summary prompt exceeds {} input tokens but there are no findings to chunk \ - (report.md alone overflows budget). Trim the report or raise max_input_tokens.", + "render prompt exceeds {} input tokens but there are no findings to chunk \ + (observations alone overflow budget). Trim task_prose entries or raise \ + max_input_tokens.", budget )); } - let chunks = chunk_findings_to_fit( + let batches = chunk_findings_to_fit( &inputs.client, cfg, original_prompt, - report_md, findings, + task_observations, budget, ) .await?; eprintln!( - "summary: staging: {} chunk(s) over {} finding(s); will render partials then combine", - chunks.len(), + "summary: staging: {} batch(es) over {} finding(s); rendering partials then combining", + batches.len(), findings.len(), ); - let mut partials = Vec::with_capacity(chunks.len()); - for (idx, chunk) in chunks.iter().enumerate() { - let note = partial_note(idx + 1, chunks.len(), findings_note_opt); + let mut partials = Vec::with_capacity(batches.len()); + for (idx, batch) in batches.iter().enumerate() { + let note = partial_note(idx + 1, batches.len()); let prompt_json = - build_partial_prompt_json(original_prompt, report_md, chunk, Some(note.as_str()))?; + build_render_prompt(original_prompt, batch, task_observations, Some(note.as_str()))?; let messages = vec![user_message(&prompt_json)]; - let label = format!("summary partial {}/{}", idx + 1, chunks.len()); + let label = format!("summary render partial {}/{}", idx + 1, batches.len()); eprintln!( - "summary: partial {}/{} — {} finding(s)", + "summary: partial {}/{} — {} finding(s), observations_chars={}", idx + 1, - chunks.len(), - chunk.len(), + batches.len(), + batch.len(), + task_observations.len(), ); let text = call_and_extract(&inputs.client, cfg, &messages, &label).await?; partials.push(text); } - // Combine pass: synthesise a dedicated system prompt that tells - // the fast agent to merge the partials without re-deriving - // structured facts. Falls back to the same model/budget config as - // the partials. let combine_system = combine_system_prompt(inputs.markdown); - let combine_cfg = CallConfig::defaults_for(inputs.model.clone()) + let mut combine_cfg = CallConfig::defaults_for(inputs.model.clone()) .with_max_tokens(inputs.max_tokens) .with_stream_label("summary combine") .with_system(combine_system); - let combine_cfg = match inputs.max_input_tokens { - Some(n) => combine_cfg.with_max_input_tokens(n), - None => combine_cfg, - }; + if let Some(n) = inputs.max_input_tokens { + combine_cfg = combine_cfg.with_max_input_tokens(n); + } let combine_json = serde_json::to_string(&json!({ "task": "combine_summaries", "original_prompt": original_prompt, "partials": partials, }))?; let combine_messages = vec![user_message(&combine_json)]; - // Pre-size the combine call. Partials are typically smaller than - // the source they cover, but a lens that expands prose can leave - // the concatenation over budget. Surface that as a clear error - // rather than letting the LLM call fail mid-stream, so the - // operator knows to raise max_input_tokens (or trim report.md). let combine_size = size_call(&inputs.client, &combine_cfg, &combine_messages, budget).await; eprintln!( "summary: combine sizing partials={} tokens={:?} budget={}", @@ -365,7 +707,7 @@ async fn stage_summary( if n > budget as u64 { return Err(anyhow!( "combined partials ({n} tokens) exceed the {budget}-token input budget — \ - raise max_input_tokens or shrink report.md" + raise max_input_tokens or shrink task_prose entries" )); } } @@ -383,41 +725,35 @@ async fn stage_summary( } /// Split findings into consecutive chunks such that each (chunk + -/// report.md + template) fits `budget` input tokens. Starts at 2 -/// parts (the caller only invokes this after the full 1-chunk -/// payload already oversized) and doubles until every partition -/// fits or each chunk is a single finding. Returns the chunks as -/// borrowed slices. +/// full task_observations + template) fits `budget` input tokens. +/// Starts at 2 parts and doubles until every partition fits or +/// each chunk is a single finding. async fn chunk_findings_to_fit<'a>( client: &Client, cfg: &CallConfig, original_prompt: &str, - report_md: &str, findings: &'a [Finding], + task_observations: &str, budget: u32, ) -> Result> { if findings.len() < 2 { return Err(anyhow!( "cannot chunk {} finding(s) to fit the {} input-token budget; \ - report.md alone is the overflow source", + observations or a single finding is the overflow source", findings.len(), budget )); } - // Size each chunk with a representative partial_note applied so - // the probe matches the real partial call within a few bytes. - // Picking idx/total from the current `parts` keeps the format! - // string aligned with what the partial call will emit. let mut parts: usize = 2; loop { let chunks = split_evenly(findings, parts); let mut all_fit = true; for (idx, chunk) in chunks.iter().enumerate() { - let probe_note = partial_note(idx + 1, chunks.len(), None); - let prompt = build_partial_prompt_json( + let probe_note = partial_note(idx + 1, chunks.len()); + let prompt = build_render_prompt( original_prompt, - report_md, chunk, + task_observations, Some(probe_note.as_str()), )?; let messages = vec![user_message(&prompt)]; @@ -434,7 +770,7 @@ async fn chunk_findings_to_fit<'a>( if parts >= findings.len() { return Err(anyhow!( "even one finding per chunk exceeds the {} input-token budget; \ - report.md is likely the overflow source", + observations are likely the overflow source", budget )); } @@ -442,9 +778,6 @@ async fn chunk_findings_to_fit<'a>( } } -/// Split `items` into `parts` contiguous slices, biggest-first when -/// the length doesn't divide evenly (so earlier chunks absorb the -/// remainder). fn split_evenly(items: &[T], parts: usize) -> Vec<&[T]> { if parts == 0 || items.is_empty() { return vec![items]; @@ -464,60 +797,57 @@ fn split_evenly(items: &[T], parts: usize) -> Vec<&[T]> { out } -fn build_prompt_json( - original_prompt: &str, - report_md: &str, - findings: &[Finding], - findings_note: Option<&str>, -) -> Result { - let findings_missing = findings.is_empty(); - let note = if findings_missing { - "findings.json absent or empty; derive the summary from report.md alone. Do not invent structured facts." - } else { - findings_note.unwrap_or("") - }; +fn build_batch_condense_prompt(batch: &[(String, TaskMaterial)]) -> Result { + let items: Vec<_> = batch + .iter() + .map(|(task_id, material)| { + let findings_touched: Vec<_> = material + .per_finding + .iter() + .map(|(id, title, analysis)| { + json!({ + "id": id, + "title": title, + "analysis": analysis, + }) + }) + .collect(); + json!({ + "task_id": task_id, + "findings_touched": findings_touched, + "task_prose": material.prose, + }) + }) + .collect(); Ok(serde_json::to_string(&json!({ - "task": "summary", - "original_prompt": original_prompt, - "report_md": report_md, - "findings": findings, - "findings_missing": findings_missing, - "note": note, + "task": "condense_tasks", + "items": items, }))?) } -fn build_partial_prompt_json( +fn build_render_prompt( original_prompt: &str, - report_md: &str, findings: &[Finding], - extra_note: Option<&str>, + task_observations: &str, + note: Option<&str>, ) -> Result { - let note = extra_note.unwrap_or(""); Ok(serde_json::to_string(&json!({ "task": "summary", "original_prompt": original_prompt, - "report_md": report_md, "findings": findings, - "findings_missing": false, - "note": note, + "task_observations": task_observations, + "note": note.unwrap_or(""), }))?) } -fn partial_note(idx: usize, total: usize, carry_over: Option<&str>) -> String { - let mut n = format!( +fn partial_note(idx: usize, total: usize) -> String { + format!( "You are rendering partial summary {idx} of {total} for the same research run. \ Cover only the findings provided in this chunk. A later stage will merge the \ partials into a single final summary, so emit the sections in the template's \ normal shape and skip any closing or global framing that would duplicate \ across partials." - ); - if let Some(extra) = carry_over { - if !extra.is_empty() { - n.push(' '); - n.push_str(extra); - } - } - n + ) } fn combine_system_prompt(markdown: bool) -> String { @@ -529,10 +859,21 @@ fn combine_system_prompt(markdown: bool) -> String { than listing them twice. Preserve the style, tone, structure, and line \ wrapping the partials already use; do not invent new section headings or \ framing. If the partials open with a shared contextual lead-in, keep one copy \ - at the top. End the output with a blank line." + at the top. Severity ordering is already baked into the partials — preserve \ + it; do not re-sort across the merged output. End the output with a blank line." ) } +/// Rank used by the severity sort. Higher = more severe. +fn severity_rank(s: Severity) -> u8 { + match s { + Severity::Critical => 4, + Severity::High => 3, + Severity::Medium => 2, + Severity::Low => 1, + } +} + fn user_message(content: &str) -> Message { Message { role: "user".into(), @@ -542,13 +883,22 @@ fn user_message(content: &str) -> Message { } } +fn truncate(s: &str, n: usize) -> String { + // char-boundary safe; task ids are ASCII today but the + // summariser shouldn't panic if an operator ever stuffs a + // multi-byte tag into a todo name. + let mut chars = s.chars(); + let head: String = chars.by_ref().take(n).collect(); + if chars.next().is_some() { + format!("{head}…") + } else { + head + } +} + /// Safety factor on the chars/4 heuristic. When the cheap estimate /// comes in at <= budget * SAFE_FRAC, we trust it and skip the -/// count_tokens_exact round-trip; the trip costs one API hit per -/// summary attempt and is pure overhead for payloads well below -/// budget. 0.75 leaves slack for the chars/4 estimate's own -/// inaccuracy (it undercounts long identifiers and multi-byte -/// code points). +/// count_tokens_exact round-trip. const SAFE_FRAC: f64 = 0.75; async fn count_or_estimate(client: &Client, cfg: &CallConfig, messages: &[Message]) -> Option { @@ -558,22 +908,12 @@ async fn count_or_estimate(client: &Client, cfg: &CallConfig, messages: &[Messag Some(cheap_estimate(cfg, messages)) } -/// chars/4 estimate over user content + system prompt. Mirrors the -/// rate-limit path's fallback heuristic. Used both as a gate before -/// the exact count call and as the last-resort answer when the exact -/// endpoint itself fails. fn cheap_estimate(cfg: &CallConfig, messages: &[Message]) -> u64 { let user_chars: usize = messages.iter().map(|m| m.content.len()).sum(); let system_chars = cfg.system.as_ref().map(|s| s.len()).unwrap_or(0); ((user_chars + system_chars) as u64) / 4 } -/// Sizing gate used before every LLM call in the summary pipeline. -/// Skip the count_tokens_exact round-trip when the chars/4 estimate -/// is comfortably under budget — a ~2× cost saving on small runs. -/// When the estimate is close to (or over) budget, fall through to -/// the exact count so the staging decision reflects the real token -/// count rather than a lossy heuristic. async fn size_call( client: &Client, cfg: &CallConfig, @@ -623,3 +963,207 @@ fn extract_text(resp: &kres_llm::request::MessagesResponse) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + use kres_core::findings::{FindingDetail, RelevantFileSection, RelevantSymbol, TaskProse}; + + fn f(id: &str, sev: Severity, status: Status, details: Vec<(&str, &str)>) -> Finding { + Finding { + id: id.to_string(), + title: format!("title {id}"), + severity: sev, + status, + relevant_symbols: Vec::::new(), + relevant_file_sections: Vec::::new(), + summary: "s".into(), + reproducer_sketch: "r".into(), + impact: "i".into(), + mechanism_detail: None, + fix_sketch: None, + open_questions: Vec::new(), + first_seen_task: details.first().map(|(t, _)| t.to_string()), + last_updated_task: details.last().map(|(t, _)| t.to_string()), + related_finding_ids: Vec::new(), + details: details + .into_iter() + .map(|(t, a)| FindingDetail { + task: t.to_string(), + analysis: a.to_string(), + }) + .collect(), + reactivate: false, + } + } + + #[test] + fn severity_sort_desc_with_stable_within_band() { + let findings = [ + f("a", Severity::Low, Status::Active, vec![]), + f("b", Severity::Critical, Status::Active, vec![]), + f("c", Severity::Medium, Status::Active, vec![]), + f("d", Severity::Critical, Status::Active, vec![]), + f("e", Severity::High, Status::Active, vec![]), + ]; + let mut got: Vec = findings.to_vec(); + got.sort_by(|a, b| severity_rank(b.severity).cmp(&severity_rank(a.severity))); + let ids: Vec<&str> = got.iter().map(|x| x.id.as_str()).collect(); + // Critical (b,d) first (input order), then High (e), Medium (c), Low (a). + assert_eq!(ids, vec!["b", "d", "e", "c", "a"]); + } + + #[test] + fn invalidated_findings_filtered_out() { + let findings = [ + f("live", Severity::Medium, Status::Active, vec![]), + f("dead", Severity::High, Status::Invalidated, vec![]), + ]; + let kept: Vec<&Finding> = findings + .iter() + .filter(|f| f.status != Status::Invalidated) + .collect(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "live"); + } + + #[test] + fn bucket_task_material_covers_findings_and_prose() { + let findings = vec![ + f( + "one", + Severity::High, + Status::Active, + vec![("task-a", "analysis-a1"), ("task-b", "analysis-b")], + ), + f( + "two", + Severity::Low, + Status::Active, + vec![("task-a", "analysis-a2")], + ), + ]; + let file = FindingsFile { + findings: findings.clone(), + updated_at: None, + tasks_since_change: 0, + turn_n: None, + task_prose: vec![ + TaskProse { + task: "task-b".into(), + created_at: chrono::Utc::now(), + prose: "prose-b-1".into(), + }, + TaskProse { + task: "task-c".into(), + created_at: chrono::Utc::now(), + prose: "prose-c".into(), + }, + TaskProse { + task: "task-b".into(), + created_at: chrono::Utc::now(), + prose: "prose-b-2".into(), + }, + ], + }; + let (order, map) = bucket_task_material(&findings, &file); + // Order: task-a first (findings[0].details[0]), then task-b + // (findings[0].details[1]), then task-c (task_prose-only). + assert_eq!(order, vec!["task-a", "task-b", "task-c"]); + let a = map.get("task-a").unwrap(); + assert_eq!(a.per_finding.len(), 2); + assert_eq!(a.prose, ""); + let b = map.get("task-b").unwrap(); + assert_eq!(b.per_finding.len(), 1); + // Both prose entries for task-b were concatenated with a + // blank-line separator. + assert!(b.prose.contains("prose-b-1")); + assert!(b.prose.contains("prose-b-2")); + assert!(b.prose.contains("\n\n")); + let c = map.get("task-c").unwrap(); + assert_eq!(c.per_finding.len(), 0); + assert_eq!(c.prose, "prose-c"); + } + + #[test] + fn task_prose_only_tasks_retain_observations() { + // Regression: a past narrowing implementation used + // finding.first_seen_task / last_updated_task to decide + // which observations survived. Tasks that only emitted + // TaskProse (never touched a finding) have empty stamps on + // every finding, so they would silently drop out of both + // single-shot and partial renders. Current design: bucket + // collects task_prose-only tasks alongside detail-bearing + // ones; this test pins that by checking the bucket's + // output. + let findings = vec![f( + "one", + Severity::High, + Status::Active, + vec![("task-a", "a")], + )]; + let file = FindingsFile { + findings: findings.clone(), + updated_at: None, + tasks_since_change: 0, + turn_n: None, + task_prose: vec![TaskProse { + task: "task-prose-only".into(), + created_at: chrono::Utc::now(), + prose: "general narrative".into(), + }], + }; + let (order, map) = bucket_task_material(&findings, &file); + assert!(order.contains(&"task-prose-only".to_string())); + assert_eq!(map.get("task-prose-only").unwrap().prose, "general narrative"); + } + + #[test] + fn join_blocks_drops_empty_and_doesnt_double_blank_lines() { + let blocks = vec![ + "alpha one\nalpha two\n".to_string(), + "".to_string(), + " \n\n".to_string(), + "beta".to_string(), + ]; + let out = join_blocks(&blocks); + assert_eq!(out, "alpha one\nalpha two\n\nbeta\n"); + } + + #[test] + fn join_blocks_empty_input_returns_empty() { + let blocks: Vec = vec![]; + assert!(join_blocks(&blocks).is_empty()); + } + + #[test] + fn build_batch_condense_prompt_carries_every_task() { + let m1 = TaskMaterial { + per_finding: vec![("f1".into(), "t1".into(), "a1".into())], + prose: "p1".into(), + }; + let m2 = TaskMaterial { + per_finding: vec![], + prose: "p2".into(), + }; + let batch = vec![("task-a".to_string(), m1), ("task-b".to_string(), m2)]; + let prompt = build_batch_condense_prompt(&batch).unwrap(); + let v: serde_json::Value = serde_json::from_str(&prompt).unwrap(); + assert_eq!(v["task"], "condense_tasks"); + let items = v["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["task_id"], "task-a"); + assert_eq!(items[0]["task_prose"], "p1"); + assert_eq!(items[1]["task_id"], "task-b"); + assert_eq!(items[1]["findings_touched"].as_array().unwrap().len(), 0); + } + + #[test] + fn embedded_condense_prompt_is_registered() { + // Build-time guarantee that the condense pass has a system + // prompt to load; otherwise run_summary panics at runtime. + let body = kres_agents::embedded_prompts::lookup("condense-task.system.md") + .expect("condense-task.system.md must be embedded"); + assert!(!body.trim().is_empty()); + } +} diff --git a/kres/src/main.rs b/kres/src/main.rs index 8c4a5a9..0edb705 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -540,23 +540,20 @@ async fn run_repl(args: ReplArgs) -> Result<()> { )); } }; - if !report_path.exists() { - return Err(anyhow::anyhow!( - "--summary: report {} does not exist", - report_path.display() - )); - } - let findings_opt = findings_base.as_ref().and_then(|p| { - if p.exists() { - Some(p.clone()) - } else { - eprintln!( - "--summary: warning: findings file {} does not exist — continuing with report.md only", + let findings_path = match findings_base.as_ref() { + Some(p) if p.exists() => p.clone(), + Some(p) => { + return Err(anyhow::anyhow!( + "--summary: findings file {} does not exist", p.display() - ); - None + )); } - }); + None => { + return Err(anyhow::anyhow!( + "--summary: no findings path configured (pass --findings or --results)" + )); + } + }; let (fast_client, fast_model, fast_max_tokens, fast_max_input) = kres_repl::summary::load_fast_for_summary(&fast_cfg_path, &settings)?; // `results_dir` is already cwd when --results was absent (see @@ -574,25 +571,21 @@ async fn run_repl(args: ReplArgs) -> Result<()> { let p = d.join("prompt.md"); match std::fs::read_to_string(&p) { Ok(s) if !s.trim().is_empty() => { - eprintln!("--summary: prompt = {}", p.display()); + eprintln!("--summary: prompt = {}", p.display()); Some(s) } _ => None, } }); - eprintln!("--summary: report = {}", report_path.display()); - if let Some(ref p) = findings_opt { - eprintln!("--summary: findings= {}", p.display()); - } - eprintln!("--summary: output = {}", output_path.display()); + eprintln!("--summary: findings = {}", findings_path.display()); + eprintln!("--summary: output = {}", output_path.display()); // Race the summary call against SIGINT so ctrl-c actually // aborts the HTTP request instead of hanging until the // streaming response completes. Without this branch the REPL // path installs its own ctrl-c handler but --summary has // none, so SIGINT just sits in the tokio signal queue. let summary_fut = kres_repl::summary::run_summary(kres_repl::summary::SummaryInputs { - report_path, - findings_path: findings_opt, + findings_path, output_path, template_path: args.template.clone(), markdown, From 16923a0ab3d771c1e9b8f1d81bbad1d62674c6e3 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Fri, 24 Apr 2026 04:44:01 -0700 Subject: [PATCH 49/76] review-template.md: Adjust focus on the target area The focus was too narrow, missing some bugs. Signed-off-by: Chris Mason --- configs/prompts/review-template.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/configs/prompts/review-template.md b/configs/prompts/review-template.md index 40cebcc..99516ed 100644 --- a/configs/prompts/review-template.md +++ b/configs/prompts/review-template.md @@ -3,11 +3,12 @@ function name, commit ref (e.g. `HEAD`), diff, or code snippet supplied by the operator. We're doing a deep security and bug analysis of that target. -Focus on just the target itself and the supporting code it calls, -without expanding out into the rest of the kernel. Pay special -attention to chains of events that trigger obscure bugs. +Focus on the target provided and the bugs that can be triggered +by using that target. Pay special attention to chains of events +that can trigger obscure bugs, but make sure the bugs somehow +involve the target. -Find *every* bug you can in the target area. Do not stop after the +Find *every* bug you can involving the target. Do not stop after the first finding. Each lens below must exhaustively enumerate its issues — list every distinct defect, not just the worst one. A lens that reports only one finding is acceptable only if you are From 9c7cab0dc96c22401d459a8c20c736d23ed6bc08 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Fri, 24 Apr 2026 05:15:54 -0700 Subject: [PATCH 50/76] tools: surface empty git output so agents stop looping A successful git query that matches nothing (e.g. `log -- ` over a window with no commits touching those paths) returns exit 0 with empty stdout and stderr. The main agent wraps each tool result in a `--- git ... ---\n` envelope, so an empty body is indistinguishable from "the tool never ran" and the fast/slow agents loop, re-requesting the same query. Observed in kres session 81d6b079: the fast agent emitted the same `git log -- include/net/ip_tunnels.h ...` five times and each empty reply was read as MISSING by the goal-check agent ("The git log returned no output (MISSING) ... the required operation was not completed"). Running that query on the tree by hand confirmed the range genuinely has zero matching commits; the tool was not blocked. Fix by appending "(no output)\n" when both stdout and stderr come back empty so callers can tell "ran, produced nothing" apart from "never ran." Signed-off-by: Chris Mason --- kres-agents/src/tools.rs | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/kres-agents/src/tools.rs b/kres-agents/src/tools.rs index cd3427f..3ca153c 100644 --- a/kres-agents/src/tools.rs +++ b/kres-agents/src/tools.rs @@ -530,6 +530,18 @@ pub async fn git(workspace: &Path, args: &GitArgs) -> Result let err = String::from_utf8_lossy(&out.stderr).to_string(); text.push_str(&err); } + // A successful git query with no matches (e.g. a `log -- + // ` that hits zero commits) returns exit 0 with empty + // stdout+stderr. The main agent wraps tool output in a `--- git + // ... ---\n` envelope, so an empty body reads as "the tool + // never ran" and the fast/slow agents loop, re-requesting the + // same query. Emit an explicit marker so the caller can tell + // "ran, produced nothing" apart from "never ran". (Observed in + // session 81d6b079, where `git log -- ip_tunnels.h` was + // issued five times and each empty reply was read as MISSING.) + if text.is_empty() { + text.push_str("(no output)\n"); + } Ok(text) } @@ -1133,6 +1145,39 @@ mod tests { } } + #[tokio::test] + async fn git_marks_empty_output_as_no_output() { + // Build a repo with one commit, then run a `log` query that + // matches nothing. git exits 0 with empty stdout+stderr; the + // tool must return a non-empty marker so the agent can tell + // "ran with no matches" apart from "never ran". + let dir = tmpdir("git-empty"); + let run = |argv: &[&str]| { + let status = std::process::Command::new("git") + .args(argv) + .current_dir(&dir) + .output() + .expect("git run"); + assert!(status.status.success(), "git {argv:?} failed"); + }; + run(&["init", "-q", "-b", "main"]); + run(&["config", "user.email", "t@t"]); + run(&["config", "user.name", "t"]); + std::fs::write(dir.join("a.txt"), b"hi").unwrap(); + run(&["add", "a.txt"]); + run(&["commit", "-q", "-m", "seed"]); + // Path filter that matches zero commits. + let args: GitArgs = + serde_json::from_value(serde_json::json!({"command": "log --oneline -- missing.c"})) + .unwrap(); + let text = git(&dir, &args).await.unwrap(); + assert!( + text.contains("(no output)"), + "expected (no output) marker, got {text:?}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn git_accepts_cmd_alias() { // bugs.md#L2 — alias `cmd` must map to `command`. From acf20c513993c90494b8d7f28f0476893dbf1280 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Fri, 24 Apr 2026 08:55:52 -0700 Subject: [PATCH 51/76] kres: add --export DIR to emit a per-finding folder tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators who want to share or review individual findings currently have to grep through findings.json or carve up report.md by hand. Add a --export DIR flag that iterates findings.json (honouring --results / --findings like --summary does) and writes one subfolder per finding. Each entry is DIR//, where is the finding id sanitized for directory use. Collisions after sanitizing get a numeric suffix so ids that only differ in punctuation don't silently overwrite. Each folder carries a meta.yaml (id, title, severity, status, workspace git HEAD sha + subject, cross-refs, symbol and file-section locations, open_questions) and a FINDING.md with the full body — summary, mechanism, reproducer, impact, fix sketch, open questions, relevant symbol definitions, file section contents, and the per-task analysis details captured in finding.details. Signed-off-by: Chris Mason --- README.md | 17 +- configs/prompts/export-metadata.yaml | 27 + .../prompts/slow-code-agent-audit.system.md | 2 +- .../prompts/slow-code-agent-generic.system.md | 2 +- docs/exporting.md | 139 ++ docs/findings-json-format.md | 2 +- kres-agents/src/consolidate.rs | 2 + kres-agents/src/pipeline.rs | 2 +- kres-agents/src/promote.rs | 2 + kres-core/src/findings.rs | 235 +++- kres-core/src/shrink.rs | 27 +- kres-repl/src/export.rs | 1115 +++++++++++++++++ kres-repl/src/lib.rs | 2 + kres-repl/src/report.rs | 32 +- kres-repl/src/session.rs | 12 +- kres-repl/src/summary.rs | 9 +- kres/src/main.rs | 70 +- 17 files changed, 1645 insertions(+), 52 deletions(-) create mode 100644 configs/prompts/export-metadata.yaml create mode 100644 docs/exporting.md create mode 100644 kres-repl/src/export.rs diff --git a/README.md b/README.md index 998bc3c..da5516d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ parallel-lens review. ``` cd linux kres --results review --prompt 'review: fs/btrfs/ctree.c' --turns 2 + kres --summary-markdown --results review + # review/summary.md now has your results ``` `--prompt 'review: X'` invokes the embedded review template — @@ -75,6 +77,19 @@ kernel `review-prompts` repo for subsystem knowledge. Both are configured via `setup.sh` flags — see [docs/configuration.md](docs/configuration.md) for details. +## Exporting findings + +You can export results into either text or markdown: + +- [docs/summary.md](docs/summary.md) — `/summary`, + `kres --summary`, and the summary output format. + +But these scans can produce a lot of results, and churning through a giant +text file isn't the easiest way to walk them. You can also dump them into +a one-dir-per-finding format: + +- [docs/exporting.md](docs/exporting.md) — `kres --export DIR`: + ## Further reading - [docs/agents.md](docs/agents.md) — fast / main / slow / todo / @@ -83,8 +98,6 @@ configured via `setup.sh` flags — see parallel-lens review flow behind `--prompt "review:"`. - [docs/coding-tasks.md](docs/coding-tasks.md) — reproducer and fix generation (`code_output`, `code_edits`, `bash` verify). -- [docs/summary.md](docs/summary.md) — `/summary`, - `kres --summary`, and the summary output format. - [docs/turns-and-follow.md](docs/turns-and-follow.md) — when kres decides a non-interactive run is done. - [docs/action-allowlist.md](docs/action-allowlist.md) — which diff --git a/configs/prompts/export-metadata.yaml b/configs/prompts/export-metadata.yaml new file mode 100644 index 0000000..b69fe0e --- /dev/null +++ b/configs/prompts/export-metadata.yaml @@ -0,0 +1,27 @@ +# kres finding metadata +id: {{id}} +title: {{title}} +severity: {{!severity}} +status: {{!status}} +git: + sha: {{git_sha}} + subject: {{git_subject}} +{{#has_date}}date: {{date}} +{{/has_date}}{{#has_introduced_by}}introduced_by: + sha: {{introduced_by_sha}} +{{#has_introduced_by_subject}} subject: {{introduced_by_subject}} +{{/has_introduced_by_subject}}{{/has_introduced_by}}{{#has_first_seen_task}}first_seen_task: {{first_seen_task}} +{{/has_first_seen_task}}{{#has_last_updated_task}}last_updated_task: {{last_updated_task}} +{{/has_last_updated_task}}{{#has_related_finding_ids}}related_finding_ids: +{{#related_finding_ids}} - {{item}} +{{/related_finding_ids}}{{/has_related_finding_ids}}{{#has_relevant_symbols}}relevant_symbols: +{{#relevant_symbols}} - name: {{name}} + filename: {{filename}} + line: {{!line}} +{{/relevant_symbols}}{{/has_relevant_symbols}}{{#has_relevant_file_sections}}relevant_file_sections: +{{#relevant_file_sections}} - filename: {{filename}} + line_start: {{!line_start}} + line_end: {{!line_end}} +{{/relevant_file_sections}}{{/has_relevant_file_sections}}{{#has_open_questions}}open_questions: +{{#open_questions}} - {{item}} +{{/open_questions}}{{/has_open_questions}} \ No newline at end of file diff --git a/configs/prompts/slow-code-agent-audit.system.md b/configs/prompts/slow-code-agent-audit.system.md index 036f9b1..beb24d0 100644 --- a/configs/prompts/slow-code-agent-audit.system.md +++ b/configs/prompts/slow-code-agent-audit.system.md @@ -41,7 +41,7 @@ FINDINGS — emit native structured records: - EXISTING id with "status": "invalidated" → the existing record is marked invalidated and stays in the list as negative evidence. USE THIS when new code or context you just saw makes a prior finding wrong (the alleged racy store is behind a lock you missed, the OOB index is already bounded upstream, the ordering contract you thought was violated is actually enforced). Keep the summary empty to preserve the original body verbatim, or write a short incoming summary that explains WHY it's invalid — your call. Do not silently re-propose an invalidated finding unless you have new evidence that reverses the invalidation. - EXISTING id (invalidated) + "reactivate": true → the existing record flips back to Status::Active. Use this ONLY when you have discovered new code or context that reverses a prior invalidation (e.g. the guard you thought covered the race turns out to be elided under a specific config). Set "reactivate": true on the incoming delta and write a fresh summary explaining the reversal. Without the explicit "reactivate" flag, an incoming "status": "active" on an invalidated record is IGNORED — invalidation is otherwise sticky. You do NOT return the full list. Emit ONLY the entries you are adding, extending, invalidating, or reactivating this turn. -- Per-finding schema: {id (snake_case slug ≤40 chars), title, severity (low|medium|high|critical), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. +- Per-finding schema: {id (snake_case slug ≤40 chars), title, severity (low|medium|high), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. - 'relevant_symbols' is an array of {name, filename, line, definition} records. Copy the actual source from the 'symbols' field you received — only the ones the reader needs to understand THIS bug. Do NOT copy the whole symbols array. INCLUDE invariant-establishing symbols even when they're not at the bug site: the init / registration function that assigns the function pointer, populates the ring slot, or sets up the single-producer invariant the bug depends on. A reproducer author needs those anchors or wastes time re-deriving them. - 'relevant_file_sections' is an array of {filename, line_start, line_end, content} records for snippets that aren't whole functions (headers, constants, macro tables). Optional if relevant_symbols covers everything. - 'summary' must cite code as 'filename:line' and state: (a) which DMA-sourced / user-controlled / racy value flows where, (b) which bound / guard / lock is missing or violated, (c) what the CONCRETE kernel object affected actually is — 'tx_ring[8] is an array of struct bnxt_tx_ring_info* pointers, and the field at offset 0 is tx_int, a function pointer' is the level of detail required when the OOB/UAF target is exploitable. 'Heap corruption' alone is not enough; name what gets written with what. Use as many sentences as that takes — do not truncate to hit a length target. diff --git a/configs/prompts/slow-code-agent-generic.system.md b/configs/prompts/slow-code-agent-generic.system.md index 7ae0c77..f557d51 100644 --- a/configs/prompts/slow-code-agent-generic.system.md +++ b/configs/prompts/slow-code-agent-generic.system.md @@ -30,7 +30,7 @@ ANALYSIS — the primary artifact: - Keep it tight. Generic-mode answers are one-question-one-answer, not multi-page reviews. FINDINGS — only when a bug actually surfaces: -- The findings pipeline is live for generic-mode tasks: if in the course of answering the question you spot an actionable bug, emit a Finding. Schema matches the audit flow: {id, title, severity (low|medium|high|critical), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. +- The findings pipeline is live for generic-mode tasks: if in the course of answering the question you spot an actionable bug, emit a Finding. Schema matches the audit flow: {id, title, severity (low|medium|high), status ('active' default), relevant_symbols, relevant_file_sections, summary, reproducer_sketch, impact, mechanism_detail (optional), fix_sketch (optional), open_questions (optional), related_finding_ids (optional)}. - Do NOT invent findings to "add value". A factual-question task that uncovers no bug emits an empty findings array. The question was the goal; findings are incidental. - Every bug you describe in 'analysis' prose MUST also appear as a Finding — the delta-apply pass downstream reads ONLY the findings array. A bug that exists only in prose will be LOST. - DELTA SEMANTICS — the findings array is applied as a delta keyed by 'id' by a deterministic Rust pass, not an LLM merger. NEW id appends; EXISTING id (matching a 'previous_findings' entry) updates the existing record in place (union relevant_symbols / relevant_file_sections / related_finding_ids / open_questions, non-empty prose fields overwrite, severity only rises); EXISTING id with "status": "invalidated" flips the existing record to invalidated — use this when new context you just saw makes a prior finding wrong (guard you missed, bound already enforced, ordering actually honoured). Emit ONLY entries you are adding, extending, or invalidating this turn, never the full list. diff --git a/docs/exporting.md b/docs/exporting.md new file mode 100644 index 0000000..865c2f9 --- /dev/null +++ b/docs/exporting.md @@ -0,0 +1,139 @@ +# Exporting findings + +`kres --export DIR` takes the findings from one run and writes a +per-finding folder tree you can share, review, or paste into tickets +without having to grep through `findings.json` by hand. + +## Invocation + +``` +kres --results --export [--workspace ] +``` + +- `--results ` points at a previous kres run (anywhere its + `findings.json` lives). `--findings ` also works. +- `--export ` is the target. It is created if missing; it + is not emptied first, so a re-export on top of an old one stacks + new `/` directories alongside any stale ones. +- `--workspace ` is the source tree the findings refer to. + kres probes `git -C ` for the HEAD sha and subject and + records them on every exported finding. Defaults to the current + directory. + +No REPL, no MCP, no orchestrator — `--export` loads the findings +file, writes the tree, and exits. + +## Per-finding layout + +Each finding lands at `//` with two files: + +``` +/ + / + metadata.yaml + FINDING.md + / + metadata.yaml + FINDING.md + ... +``` + +`` is the finding's `id`, sanitized so it works as a directory +name (non-alphanumeric characters collapse to `_`, runs squashed, +leading/trailing `_` trimmed). If two findings sanitize to the same +tag, the second one gets a `-2` suffix (then `-3`, …). + +### `metadata.yaml` + +YAML metadata rendered from +`configs/prompts/export-metadata.yaml` — a compact mustache-lite +template embedded in the kres binary. Operators can shadow it by +dropping a replacement at `~/.kres/prompts/export-metadata.yaml`. + +Fields: + +- `id`, `title` — finding identity. +- `severity` — `low` / `medium` / `high`. +- `status` — `active` or `invalidated`. +- `date` — RFC3339 timestamp of the first task that inserted this + finding (`Finding.first_seen_at`). Stamped once on insert, never + shifted by later merges. When the source findings.json predates + the field and a record has no stamp, the export falls back to + wall-clock now so the row still carries a date — note that a + re-export of that legacy record will show a different timestamp + each time, while freshly-discovered findings keep a stable one. +- `git:` — workspace HEAD `sha` and commit `subject` at export + time. +- `introduced_by:` — `sha` (required) and `subject` (optional) + when a task has attributed the bug to a specific commit. + Omitted entirely until that happens. +- `first_seen_task`, `last_updated_task` — provenance stamps from + the store. +- `related_finding_ids` — cross-references by id. +- `relevant_symbols` — `{name, filename, line}` triples. +- `relevant_file_sections` — `{filename, line_start, line_end}`. +- `open_questions` — unresolved investigation threads. + +The template engine supports three forms: + +- `{{var}}` — scalar, auto-quoted as a YAML double-quoted string. +- `{{!var}}` — scalar emitted raw (for enums / ints that are safe + to inline unquoted). +- `{{#var}}...{{/var}}` — section. Renders once for each item when + `var` is a list, once when it's a non-empty scalar, and is + skipped when missing or empty. + +See `kres-repl/src/export.rs` for the context keys populated per +finding. + +### `FINDING.md` + +Human-readable body rendered directly from the stored Finding: + +- Header block with severity, status, `Introduced by`, first/last + seen task, and a Related line that renders each cross-reference + as `[`id`](../tag/FINDING.md)` so you can click through. +- `## Summary`, `## Mechanism` (when `mechanism_detail` is set), + `## Reproducer`, `## Impact`, `## Fix sketch` (when + `fix_sketch` is set), `## Open questions` (when any). +- `## Relevant symbols` — each entry lists `name` at + `filename:line` with the captured definition in a fenced block. +- `## Relevant file sections` — labelled by filename and line + range, with captured content in a fenced block. +- `## Task details` — one subsection per task that contributed + analysis, carrying the task's verbatim `effective_analysis` + prose. + +Nothing in the export consults report.md — every field comes from +`findings.json` via `FindingsStore::snapshot()`. + +## Index file + +``` +kres --export-index +``` + +Walks every `/metadata.yaml` under `` and writes +`/INDEX.md` — a single markdown table of every finding, +sorted by severity (`high` → `medium` → `low`) and, within each +tier, by `date` ascending so long-standing bugs sit at the top. +Entries with no `date` field sink to the bottom of their tier. +Each row links to that finding's `FINDING.md`. No `findings.json` +is consulted — the index reflects whatever is currently on disk, +so hand-edits to individual `metadata.yaml` files show up on the +next run. + +## Typical flow + +``` +kres --results run1 --prompt 'review: fs/btrfs/ctree.c' --turns 5 +kres --results run1 --export kres-bugs --workspace . +less kres-bugs/INDEX.md +less kres-bugs//FINDING.md + +# if you update severities or status, reindex +kres --export-index kres-bugs +``` + +From there the folders are yours to grep, diff, commit, or paste +into ticketing systems alongside the `git:` attribution. diff --git a/docs/findings-json-format.md b/docs/findings-json-format.md index 3187852..422dc6d 100644 --- a/docs/findings-json-format.md +++ b/docs/findings-json-format.md @@ -78,7 +78,7 @@ Rationale: |---|---|---| | `id` | string | Short snake_case slug, ≤40 chars. Stable across updates. | | `title` | string | One-line human title. | -| `severity` | enum | `low` / `medium` / `high` / `critical`. Scored by exploit potential, not textbook CVSS. | +| `severity` | enum | `low` / `medium` / `high`. Scored by exploit potential, not textbook CVSS. Legacy `critical` values in pre-existing findings.json files are folded into `high` on load. | | `status` | enum | `active` or `invalidated`. Default `active`. | | `relevant_symbols` | array[object] | **Embedded** symbol records that the reader needs to understand the bug. Each: `{name, filename, line, definition}`. Pull only what's actually referenced in summary/reproducer_sketch — NOT the entire session's symbol list. At least one required. | | `relevant_file_sections` | array[object] | **Embedded** source slices that aren't whole symbols (headers, tables, assembly, macros). Each: `{filename, line_start, line_end, content}`. Optional if every cited region is captured via `relevant_symbols`. | diff --git a/kres-agents/src/consolidate.rs b/kres-agents/src/consolidate.rs index 806e8da..1a6a2f2 100644 --- a/kres-agents/src/consolidate.rs +++ b/kres-agents/src/consolidate.rs @@ -254,6 +254,8 @@ mod tests { related_finding_ids: vec![], reactivate: false, details: vec![], + introduced_by: None, + first_seen_at: None, } } diff --git a/kres-agents/src/pipeline.rs b/kres-agents/src/pipeline.rs index 27f75d6..56569e0 100644 --- a/kres-agents/src/pipeline.rs +++ b/kres-agents/src/pipeline.rs @@ -795,7 +795,7 @@ impl Orchestrator { - Do NOT invent new bugs or analysis. Transcribe the prose.\n\ - Every actionable bug described in the prose MUST appear as a \ Finding record with this schema: id (snake_case slug), title, \ - severity (low|medium|high|critical), status ('active'), \ + severity (low|medium|high), status ('active'), \ relevant_symbols (array of {{name, filename, line, definition}}), \ relevant_file_sections (array of {{filename, line_start, \ line_end, content}}), summary, reproducer_sketch, impact. \ diff --git a/kres-agents/src/promote.rs b/kres-agents/src/promote.rs index f7a4fae..e562763 100644 --- a/kres-agents/src/promote.rs +++ b/kres-agents/src/promote.rs @@ -272,6 +272,8 @@ mod tests { related_finding_ids: vec![], reactivate: false, details: vec![], + introduced_by: None, + first_seen_at: None, } } diff --git a/kres-core/src/findings.rs b/kres-core/src/findings.rs index 359f98f..5074b95 100644 --- a/kres-core/src/findings.rs +++ b/kres-core/src/findings.rs @@ -42,13 +42,30 @@ pub enum FindingsError { NoParent(PathBuf), } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "lowercase")] pub enum Severity { Low, Medium, High, - Critical, +} + +/// Legacy findings.json files written before the `critical` tier was +/// retired still carry `"severity": "critical"`. Map those into +/// `High` on load so old stores keep working without a migration. +/// New writes always serialize as `low` / `medium` / `high`. +impl<'de> Deserialize<'de> for Severity { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + match s.as_str() { + "low" => Ok(Severity::Low), + "medium" => Ok(Severity::Medium), + "high" | "critical" => Ok(Severity::High), + other => Err(serde::de::Error::custom(format!( + "unknown severity {other:?} (expected low / medium / high)" + ))), + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -124,6 +141,15 @@ pub struct Finding { pub first_seen_task: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_updated_task: Option, + + /// Wall-clock timestamp of the first apply_delta that inserted + /// this finding. Stamped once on insert; never updated by + /// subsequent applies so the "when was this discovered" signal + /// stays stable. Missing on findings loaded from pre-field + /// findings.json files — those have no authoritative discovery + /// date on record. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_seen_at: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub related_finding_ids: Vec, @@ -144,6 +170,22 @@ pub struct Finding { /// is stripped before the entry enters the list. #[serde(default, skip_serializing_if = "is_false")] pub reactivate: bool, + + /// Commit that introduced the bug, once a task has attributed + /// the finding to a specific SHA. Left `None` until a later + /// investigation fills it in. Only `sha` is mandatory; the + /// subject line is a best-effort convenience so consumers + /// (exports, summaries, review comments) don't need a second + /// `git show` round-trip to print the attribution. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub introduced_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct IntroducedBy { + pub sha: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub subject: String, } fn is_false(b: &bool) -> bool { @@ -417,6 +459,13 @@ pub fn apply_delta_to_list( } new_entry.last_updated_task = Some(t.to_string()); } + // Stamp discovery time on first insert. An incoming + // delta that already carries a first_seen_at (e.g. + // a migration import) is preserved; otherwise use + // wall-clock now. Never updated by subsequent merges. + if new_entry.first_seen_at.is_none() { + new_entry.first_seen_at = Some(Utc::now()); + } current.push(new_entry); let last_idx = current.len() - 1; record_detail(&mut current[last_idx], task_id, task_analysis); @@ -496,6 +545,7 @@ fn merge_into(existing: &mut Finding, incoming: &Finding, task_id: Option<&str>) changed |= prefer_longer(&mut existing.impact, &incoming.impact); changed |= prefer_longer_opt(&mut existing.mechanism_detail, &incoming.mechanism_detail); changed |= prefer_longer_opt(&mut existing.fix_sketch, &incoming.fix_sketch); + changed |= merge_introduced_by(&mut existing.introduced_by, &incoming.introduced_by); // Union collections. changed |= union_symbols(&mut existing.relevant_symbols, &incoming.relevant_symbols); @@ -539,6 +589,43 @@ fn prefer_longer(existing: &mut String, incoming: &str) -> bool { false } +/// Merge an incoming `introduced_by` into an existing one. Rules: +/// - Incoming `None` or empty `sha`: no-op. +/// - Existing `None`: take incoming (both sha and subject). +/// - Existing `Some` with same `sha`: take incoming `subject` if it +/// is non-empty AND longer than the current one (matches the +/// prose-downgrade guard used elsewhere). +/// - Existing `Some` with a DIFFERENT non-empty `sha`: latest wins, +/// including subject. A later task may have attributed the bug +/// more precisely, and keeping the old sha silently would mask +/// that. +fn merge_introduced_by( + existing: &mut Option, + incoming: &Option, +) -> bool { + let Some(inc) = incoming else { return false }; + if inc.sha.is_empty() { + return false; + } + match existing { + None => { + *existing = Some(inc.clone()); + true + } + Some(cur) if cur.sha == inc.sha => { + if !inc.subject.is_empty() && inc.subject.len() > cur.subject.len() { + cur.subject = inc.subject.clone(); + return true; + } + false + } + Some(_) => { + *existing = Some(inc.clone()); + true + } + } +} + fn prefer_longer_opt(existing: &mut Option, incoming: &Option) -> bool { let Some(inc) = incoming else { return false }; if inc.is_empty() { @@ -742,6 +829,8 @@ mod tests { related_finding_ids: vec![], reactivate: false, details: vec![], + introduced_by: None, + first_seen_at: None, } } @@ -1068,19 +1157,157 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[tokio::test] + async fn introduced_by_takes_first_attribution_and_latest_wins() { + let dir = tmp_dir("introduced-by"); + let base = dir.join("findings.json"); + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a")], Some("t1"), None) + .await + .unwrap(); + assert!(store.snapshot().await[0].introduced_by.is_none()); + // Empty sha is a no-op. + let mut noop = sample_finding("a"); + noop.introduced_by = Some(IntroducedBy { + sha: "".into(), + subject: "ignored".into(), + }); + store.apply_delta(&[noop], Some("t2"), None).await.unwrap(); + assert!(store.snapshot().await[0].introduced_by.is_none()); + // First real attribution sticks. + let mut first = sample_finding("a"); + first.introduced_by = Some(IntroducedBy { + sha: "abc".into(), + subject: "short".into(), + }); + store.apply_delta(&[first], Some("t3"), None).await.unwrap(); + let snap = store.snapshot().await; + let ib = snap[0].introduced_by.as_ref().unwrap(); + assert_eq!(ib.sha, "abc"); + assert_eq!(ib.subject, "short"); + // Same sha, longer subject → subject upgraded. + let mut upgrade = sample_finding("a"); + upgrade.introduced_by = Some(IntroducedBy { + sha: "abc".into(), + subject: "a much longer subject line".into(), + }); + store + .apply_delta(&[upgrade], Some("t4"), None) + .await + .unwrap(); + let ib = store.snapshot().await[0].introduced_by.clone().unwrap(); + assert_eq!(ib.subject, "a much longer subject line"); + // Different sha → latest wins. + let mut reattrib = sample_finding("a"); + reattrib.introduced_by = Some(IntroducedBy { + sha: "def".into(), + subject: "re-attributed".into(), + }); + store + .apply_delta(&[reattrib], Some("t5"), None) + .await + .unwrap(); + let ib = store.snapshot().await[0].introduced_by.clone().unwrap(); + assert_eq!(ib.sha, "def"); + assert_eq!(ib.subject, "re-attributed"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn first_seen_at_stamps_on_insert_and_never_shifts() { + let dir = tmp_dir("first-seen"); + let base = dir.join("findings.json"); + let store = FindingsStore::new(&base).await.unwrap(); + store + .apply_delta(&[sample_finding("a")], Some("t1"), None) + .await + .unwrap(); + let ts_initial = store.snapshot().await[0].first_seen_at.unwrap(); + // Second delta on the same id must NOT bump the stamp. + let mut updated = sample_finding("a"); + updated.summary = "now with more detail".into(); + store + .apply_delta(&[updated], Some("t2"), None) + .await + .unwrap(); + let ts_after = store.snapshot().await[0].first_seen_at.unwrap(); + assert_eq!( + ts_initial, ts_after, + "first_seen_at must be stable across merges" + ); + // An incoming delta that carries an explicit first_seen_at + // for a NEW id is preserved (import / migration path). + let mut imported = sample_finding("b"); + let pinned = chrono::DateTime::parse_from_rfc3339("2020-01-02T03:04:05Z") + .unwrap() + .with_timezone(&Utc); + imported.first_seen_at = Some(pinned); + store + .apply_delta(&[imported], Some("t3"), None) + .await + .unwrap(); + let b = store + .snapshot() + .await + .into_iter() + .find(|f| f.id == "b") + .unwrap(); + assert_eq!(b.first_seen_at, Some(pinned)); + std::fs::remove_dir_all(&dir).ok(); + } + #[tokio::test] async fn severity_only_escalates() { let dir = tmp_dir("severity"); let base = dir.join("findings.json"); let store = FindingsStore::new(&base).await.unwrap(); let mut hi = sample_finding("a"); - hi.severity = Severity::Critical; + hi.severity = Severity::High; store.apply_delta(&[hi], Some("t1"), None).await.unwrap(); let mut lo = sample_finding("a"); lo.severity = Severity::Low; store.apply_delta(&[lo], Some("t2"), None).await.unwrap(); let snap = store.snapshot().await; - assert_eq!(snap[0].severity, Severity::Critical); + assert_eq!(snap[0].severity, Severity::High); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn legacy_critical_severity_loads_as_high() { + // findings.json files written before Critical was retired + // still carry `"severity": "critical"`. The custom + // Deserialize impl must fold that into High so the store + // loads cleanly; subsequent writes round-trip as "high". + let dir = tmp_dir("legacy-critical"); + let base = dir.join("findings.json"); + std::fs::write( + &base, + r#"{"findings":[{"id":"old","title":"t","severity":"critical","summary":"s","reproducer_sketch":"r","impact":"i"}]}"#, + ) + .unwrap(); + let store = FindingsStore::new(&base).await.unwrap(); + let snap = store.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].severity, Severity::High); + // Force a rewrite and confirm the on-disk payload no longer + // carries "critical" — jsondb decides whether to pretty-print + // or pack, so check via JSON parse instead of a byte grep. + store + .apply_delta(&[sample_finding("new")], Some("t1"), None) + .await + .unwrap(); + store.db.flush().await; + let raw = std::fs::read_to_string(&base).unwrap(); + assert!(!raw.contains("\"critical\"")); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let old = root["findings"] + .as_array() + .unwrap() + .iter() + .find(|f| f["id"] == "old") + .unwrap(); + assert_eq!(old["severity"], "high"); std::fs::remove_dir_all(&dir).ok(); } diff --git a/kres-core/src/shrink.rs b/kres-core/src/shrink.rs index b64bd80..5f60aac 100644 --- a/kres-core/src/shrink.rs +++ b/kres-core/src/shrink.rs @@ -7,8 +7,8 @@ //! This module provides a deterministic, severity-aware trim of a //! `Vec` down to a target char budget. Rule: //! -//! 1. Always keep Critical findings. -//! 2. Drop Low, then Medium, then High until the budget fits. +//! 1. Always keep High findings. +//! 2. Drop Low, then Medium, until the budget fits. //! 3. Within the same severity, findings without a //! `last_updated_task` label are dropped first (None sorts //! before Some(...)), followed by the lowest `last_updated_task` @@ -183,12 +183,14 @@ pub fn shrink_findings_to_budget(findings: &[Finding], char_budget: usize) -> Ve if total_char_size(findings) <= char_budget { return findings.to_vec(); } - // Produce a drop order: Low first, then Medium, then High. Keep - // Critical always. Within a tier, oldest last_updated_task first - // (None counts as "oldest" — no task label attached). + // Produce a drop order: Low first, then Medium. Keep High + // always — High is now the top tier, inheriting the "never + // dropped for budget" protection the retired Critical tier had. + // Within a tier, oldest last_updated_task first (None counts as + // "oldest" — no task label attached). let mut indexed: Vec<(usize, &Finding)> = findings.iter().enumerate().collect(); let mut drop_order: Vec = Vec::new(); - for tier in [Severity::Low, Severity::Medium, Severity::High] { + for tier in [Severity::Low, Severity::Medium] { let mut in_tier: Vec<&(usize, &Finding)> = indexed.iter().filter(|(_, f)| f.severity == tier).collect(); in_tier.sort_by(|a, b| { @@ -239,6 +241,8 @@ mod tests { related_finding_ids: vec![], reactivate: false, details: vec![], + introduced_by: None, + first_seen_at: None, } } @@ -267,13 +271,16 @@ mod tests { } #[test] - fn always_keeps_critical_even_when_over_budget() { + fn always_keeps_high_even_when_over_budget() { + // High is now the top tier — the "never drop for budget" + // protection that used to apply to Critical transferred to + // High when the Critical tier was retired. let f = vec![ - make("crit", Severity::Critical, 1_000_000), - make("high", Severity::High, 100), + make("big-high", Severity::High, 1_000_000), + make("low", Severity::Low, 100), ]; let out = shrink_findings_to_budget(&f, 100); - assert!(out.iter().any(|f| f.id == "crit")); + assert!(out.iter().any(|f| f.id == "big-high")); } #[test] diff --git a/kres-repl/src/export.rs b/kres-repl/src/export.rs new file mode 100644 index 0000000..83c81df --- /dev/null +++ b/kres-repl/src/export.rs @@ -0,0 +1,1115 @@ +//! `kres --export ` — emit a per-finding folder tree from +//! `findings.json`. +//! +//! For each finding in the store, the export writes: +//! +//! //metadata.yaml structured metadata (id, severity, +//! git HEAD sha/subject, cross-refs, +//! symbol and file-section locations) +//! //FINDING.md human-readable full body: summary, +//! mechanism, reproducer, impact, fix +//! sketch, open questions, per-task +//! analysis details +//! +//! `` is the finding's `id`, sanitized so it's safe as a +//! directory name. Collisions after sanitizing get a numeric suffix. +//! +//! The metadata.yaml body comes from a tiny mustache-like template +//! embedded at build time (`configs/prompts/export-metadata.yaml`); +//! operators can shadow the embedded copy by dropping a file at +//! `~/.kres/prompts/export-metadata.yaml`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::Utc; +use kres_core::findings::{Finding, FindingDetail, FindingsStore, Severity, Status}; + +/// Embedded default for the metadata template. Operator overrides +/// live at `~/.kres/prompts/export-metadata.yaml`. +const METADATA_TEMPLATE: &str = include_str!("../../configs/prompts/export-metadata.yaml"); + +/// Inputs to a single export run. +pub struct ExportInputs { + /// Path to `findings.json`. Required. + pub findings_path: PathBuf, + /// Target directory. Created if missing. + pub output_dir: PathBuf, + /// Workspace the findings refer to. Used to probe `git HEAD` so + /// each exported finding carries the commit state the analysis + /// was performed against. + pub workspace: PathBuf, +} + +/// Per-run workspace-git snapshot. Empty strings if the workspace +/// isn't a git repo or `git` isn't on `$PATH`. +struct GitHead { + sha: String, + subject: String, +} + +pub async fn run_export(inputs: ExportInputs) -> Result<()> { + let ExportInputs { + findings_path, + output_dir, + workspace, + } = inputs; + + if !findings_path.exists() { + return Err(anyhow::anyhow!( + "--export: findings file {} does not exist", + findings_path.display() + )); + } + + std::fs::create_dir_all(&output_dir) + .with_context(|| format!("creating export dir {}", output_dir.display()))?; + + let store = FindingsStore::new(&findings_path) + .await + .with_context(|| format!("loading findings {}", findings_path.display()))?; + let findings = store.snapshot().await; + let git = probe_git_head(&workspace); + let template = load_metadata_template(); + + // First pass: assign a stable per-finding tag so FINDING.md's + // Related section can resolve every id to the directory we're + // about to create. Collisions after sanitize_tag get a numeric + // suffix via unique_tag, so this is the authoritative id→tag + // table for the whole export. + let mut used: std::collections::HashSet = std::collections::HashSet::new(); + let mut id_to_tag: std::collections::HashMap = + std::collections::HashMap::with_capacity(findings.len()); + for f in &findings { + let tag = unique_tag(&f.id, &mut used); + id_to_tag.insert(f.id.clone(), tag); + } + + let mut written = 0usize; + for f in &findings { + let tag = &id_to_tag[&f.id]; + let finding_dir = output_dir.join(tag); + std::fs::create_dir_all(&finding_dir) + .with_context(|| format!("creating {}", finding_dir.display()))?; + write_metadata_yaml(&finding_dir.join("metadata.yaml"), f, &git, &template)?; + write_finding_md(&finding_dir.join("FINDING.md"), f, &id_to_tag)?; + written += 1; + } + + eprintln!( + "--export: wrote {} finding(s) to {}", + written, + output_dir.display() + ); + // Regenerate INDEX.md so every --export run leaves a ready-to-read + // top-level overview alongside the per-finding folders. Parses the + // metadata.yaml files we just wrote rather than reusing the + // in-memory findings list — keeps the code path identical to + // `--export-index` so the two outputs can't drift. + let index = run_export_index(&output_dir)?; + eprintln!("--export: index = {}", index.display()); + Ok(()) +} + +/// Walk `/*/metadata.yaml` and write `/INDEX.md` — one +/// row per finding, grouped by severity (High → Medium → Low), and +/// inside each group ordered by `date` ascending so the +/// longest-standing bug sits at the top. Findings with no date sink +/// to the bottom of their group but remain present. +pub fn run_export_index(dir: &Path) -> Result { + if !dir.is_dir() { + return Err(anyhow::anyhow!( + "--export-index: {} is not a directory", + dir.display() + )); + } + let mut rows: Vec = Vec::new(); + for entry in std::fs::read_dir(dir) + .with_context(|| format!("reading {}", dir.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let meta = entry.path().join("metadata.yaml"); + if !meta.exists() { + continue; + } + let yaml = std::fs::read_to_string(&meta) + .with_context(|| format!("reading {}", meta.display()))?; + rows.push(IndexRow { + tag: entry.file_name().to_string_lossy().into_owned(), + id: top_level_scalar(&yaml, "id").unwrap_or_default(), + title: top_level_scalar(&yaml, "title").unwrap_or_default(), + severity: parse_severity( + top_level_scalar(&yaml, "severity").as_deref().unwrap_or(""), + ), + status: top_level_scalar(&yaml, "status") + .unwrap_or_else(|| "active".to_string()), + date: top_level_scalar(&yaml, "date"), + }); + } + rows.sort_by(|a, b| { + // Severity desc (High first); within a tier, oldest date + // first; None dates go to the end of their tier; finally fall + // back to id for determinism. + let sev = severity_sort_key(b.severity).cmp(&severity_sort_key(a.severity)); + if sev != std::cmp::Ordering::Equal { + return sev; + } + match (a.date.as_deref(), b.date.as_deref()) { + (Some(x), Some(y)) => x.cmp(y).then_with(|| a.id.cmp(&b.id)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.id.cmp(&b.id), + } + }); + + let out_path = dir.join("INDEX.md"); + std::fs::write(&out_path, render_index(&rows)) + .with_context(|| format!("writing {}", out_path.display()))?; + Ok(out_path) +} + +#[derive(Debug)] +struct IndexRow { + tag: String, + id: String, + title: String, + severity: Option, + status: String, + date: Option, +} + +fn severity_sort_key(s: Option) -> u8 { + match s { + Some(Severity::High) => 3, + Some(Severity::Medium) => 2, + Some(Severity::Low) => 1, + None => 0, + } +} + +fn parse_severity(s: &str) -> Option { + match s { + "low" => Some(Severity::Low), + "medium" => Some(Severity::Medium), + "high" => Some(Severity::High), + _ => None, + } +} + +/// Parse a top-level scalar field from our generated metadata.yaml. +/// Recognises two shapes: +/// key: "quoted value" +/// key: unquoted-value +/// Ignores indented continuations and nested mappings. Returns the +/// raw string value (quotes and backslash escapes unwrapped). +fn top_level_scalar(yaml: &str, key: &str) -> Option { + let needle = format!("{key}: "); + for line in yaml.lines() { + // Indented lines belong to nested mappings / list items. + if line.starts_with(' ') || line.starts_with('\t') { + continue; + } + let Some(rest) = line.strip_prefix(&needle) else { + continue; + }; + let rest = rest.trim(); + if let Some(inner) = rest + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + { + return Some(unquote_yaml(inner)); + } + return Some(rest.to_string()); + } + None +} + +/// Reverse of yaml_scalar: unwrap backslash-escapes we know about. +/// Unknown escapes pass through as the literal char. +fn unquote_yaml(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('\\') => out.push('\\'), + Some('"') => out.push('"'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('t') => out.push('\t'), + Some(other) => out.push(other), + None => out.push('\\'), + } + } + out +} + +fn render_index(rows: &[IndexRow]) -> String { + let mut out = String::new(); + out.push_str("# kres findings index\n\n"); + let ts = chrono::Utc::now().to_rfc3339(); + out.push_str(&format!("_generated: {ts}_\n\n")); + if rows.is_empty() { + out.push_str("(no findings)\n"); + return out; + } + let (h, m, l, u) = rows.iter().fold((0, 0, 0, 0), |(h, m, l, u), r| match r.severity { + Some(Severity::High) => (h + 1, m, l, u), + Some(Severity::Medium) => (h, m + 1, l, u), + Some(Severity::Low) => (h, m, l + 1, u), + None => (h, m, l, u + 1), + }); + out.push_str(&format!( + "{} finding(s): {} high, {} medium, {} low", + rows.len(), + h, + m, + l + )); + if u > 0 { + out.push_str(&format!(", {u} unknown-severity")); + } + out.push_str("\n\n"); + out.push_str("| Severity | Date | Status | ID | Title |\n"); + out.push_str("|---|---|---|---|---|\n"); + for r in rows { + let sev = r + .severity + .map(|s| match s { + Severity::High => "high", + Severity::Medium => "medium", + Severity::Low => "low", + }) + .unwrap_or("?"); + let date = r.date.as_deref().unwrap_or("—"); + let title = escape_md_table_cell(&r.title); + out.push_str(&format!( + "| {sev} | {date} | {status} | [`{id}`]({tag}/FINDING.md) | {title} |\n", + status = r.status, + id = r.id, + tag = r.tag, + title = title, + )); + } + out +} + +fn escape_md_table_cell(s: &str) -> String { + // Pipes break GFM table cells; newlines break the row. Replace + // both with something that keeps the row intact. + s.replace('|', "\\|").replace('\n', " ") +} + +/// Disk override wins when it exists and is non-empty; else the +/// compiled-in copy. Mirrors the `~/.kres/commands/.md` +/// convention used by `user_commands`, but under +/// `~/.kres/prompts/` so we don't crowd the slash-commands namespace. +fn load_metadata_template() -> String { + if let Some(home) = dirs::home_dir() { + let p = home.join(".kres").join("prompts").join("export-metadata.yaml"); + if let Ok(s) = std::fs::read_to_string(&p) { + if !s.trim().is_empty() { + return s; + } + } + } + METADATA_TEMPLATE.to_string() +} + +/// Turn an arbitrary finding id into a directory-safe tag. +fn sanitize_tag(id: &str) -> String { + let mut out = String::with_capacity(id.len()); + let mut prev_underscore = false; + for c in id.chars() { + let keep = c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'; + if keep { + out.push(c); + prev_underscore = false; + } else if !prev_underscore { + out.push('_'); + prev_underscore = true; + } + } + let trimmed = out.trim_matches('_').to_string(); + if trimmed.is_empty() { + "finding".to_string() + } else { + trimmed + } +} + +fn unique_tag(id: &str, used: &mut std::collections::HashSet) -> String { + let base = sanitize_tag(id); + if used.insert(base.clone()) { + return base; + } + for n in 2u32.. { + let candidate = format!("{base}-{n}"); + if used.insert(candidate.clone()) { + return candidate; + } + } + unreachable!("u32 exhausted sanitizing tag") +} + +fn probe_git_head(workspace: &Path) -> GitHead { + GitHead { + sha: run_git(workspace, &["rev-parse", "HEAD"]).unwrap_or_default(), + subject: run_git(workspace, &["log", "-1", "--format=%s"]).unwrap_or_default(), + } +} + +fn run_git(workspace: &Path, args: &[&str]) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(workspace) + .args(args) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn write_metadata_yaml( + path: &Path, + f: &Finding, + git: &GitHead, + template: &str, +) -> Result<()> { + let ctx = build_context(f, git); + let body = render(template, &ctx); + std::fs::write(path, body).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +fn severity_str(s: Severity) -> &'static str { + match s { + Severity::Low => "low", + Severity::Medium => "medium", + Severity::High => "high", + } +} + +fn status_str(s: Status) -> &'static str { + match s { + Status::Active => "active", + Status::Invalidated => "invalidated", + } +} + +// --------------------------------------------------------------- +// Tiny mustache-like template engine. +// +// Supported syntax: +// {{key}} scalar, auto-quoted as YAML double-quoted string +// {{!key}} scalar, emitted raw (for enums / ints already +// safe to inline) +// {{#key}}...{{/key}} +// section. When the value is a list, the inner is +// rendered once per item with the item's fields in +// scope. When the value is a non-empty scalar the +// inner is rendered once. Missing / empty → skip. +// +// Scope: a single parent `Ctx` plus a per-iteration item map during +// list sections. Nested sections use the same lookup rule: item +// fields shadow parent. +// --------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum Value { + Scalar(String), + Items(Vec>), +} + +type Ctx = BTreeMap; + +fn build_context(f: &Finding, git: &GitHead) -> Ctx { + let mut c: Ctx = BTreeMap::new(); + c.insert("id".into(), Value::Scalar(f.id.clone())); + c.insert("title".into(), Value::Scalar(f.title.clone())); + c.insert("severity".into(), Value::Scalar(severity_str(f.severity).into())); + c.insert("status".into(), Value::Scalar(status_str(f.status).into())); + c.insert("git_sha".into(), Value::Scalar(git.sha.clone())); + c.insert("git_subject".into(), Value::Scalar(git.subject.clone())); + + // Use first_seen_at when the finding carries one; fall back to + // wall-clock now for legacy records (pre-first_seen_at findings.json + // files have None on every entry). The fallback means a re-export + // of a legacy store keeps a stamped `date:` line, at the cost of + // the date drifting on each export — acceptable because new + // findings going forward carry their real discovery date. Format + // is calendar-date only (YYYY-MM-DD); second-precision was noise + // for the reader and drifted on every re-export anyway. + let date_ts = f.first_seen_at.unwrap_or_else(Utc::now); + c.insert("has_date".into(), Value::Scalar("1".into())); + c.insert( + "date".into(), + Value::Scalar(date_ts.format("%Y-%m-%d").to_string()), + ); + if let Some(ref ib) = f.introduced_by { + if !ib.sha.is_empty() { + c.insert("has_introduced_by".into(), Value::Scalar("1".into())); + c.insert("introduced_by_sha".into(), Value::Scalar(ib.sha.clone())); + if !ib.subject.is_empty() { + c.insert("has_introduced_by_subject".into(), Value::Scalar("1".into())); + c.insert( + "introduced_by_subject".into(), + Value::Scalar(ib.subject.clone()), + ); + } + } + } + if let Some(ref t) = f.first_seen_task { + c.insert("has_first_seen_task".into(), Value::Scalar("1".into())); + c.insert("first_seen_task".into(), Value::Scalar(t.clone())); + } + if let Some(ref t) = f.last_updated_task { + c.insert("has_last_updated_task".into(), Value::Scalar("1".into())); + c.insert("last_updated_task".into(), Value::Scalar(t.clone())); + } + + if !f.related_finding_ids.is_empty() { + c.insert("has_related_finding_ids".into(), Value::Scalar("1".into())); + let items = f + .related_finding_ids + .iter() + .map(|id| { + let mut m = BTreeMap::new(); + m.insert("item".into(), Value::Scalar(id.clone())); + m + }) + .collect(); + c.insert("related_finding_ids".into(), Value::Items(items)); + } + if !f.relevant_symbols.is_empty() { + c.insert("has_relevant_symbols".into(), Value::Scalar("1".into())); + let items = f + .relevant_symbols + .iter() + .map(|s| { + let mut m = BTreeMap::new(); + m.insert("name".into(), Value::Scalar(s.name.clone())); + m.insert("filename".into(), Value::Scalar(s.filename.clone())); + m.insert("line".into(), Value::Scalar(s.line.to_string())); + m + }) + .collect(); + c.insert("relevant_symbols".into(), Value::Items(items)); + } + if !f.relevant_file_sections.is_empty() { + c.insert("has_relevant_file_sections".into(), Value::Scalar("1".into())); + let items = f + .relevant_file_sections + .iter() + .map(|s| { + let mut m = BTreeMap::new(); + m.insert("filename".into(), Value::Scalar(s.filename.clone())); + m.insert("line_start".into(), Value::Scalar(s.line_start.to_string())); + m.insert("line_end".into(), Value::Scalar(s.line_end.to_string())); + m + }) + .collect(); + c.insert("relevant_file_sections".into(), Value::Items(items)); + } + if !f.open_questions.is_empty() { + c.insert("has_open_questions".into(), Value::Scalar("1".into())); + let items = f + .open_questions + .iter() + .map(|q| { + let mut m = BTreeMap::new(); + m.insert("item".into(), Value::Scalar(q.clone())); + m + }) + .collect(); + c.insert("open_questions".into(), Value::Items(items)); + } + + c +} + +/// Render `template` against `ctx`. Lookup for a key inside a list +/// iteration first checks the item's own fields, then the parent +/// context. +fn render(template: &str, ctx: &Ctx) -> String { + render_scoped(template, ctx, None) +} + +fn render_scoped(template: &str, parent: &Ctx, item: Option<&Ctx>) -> String { + let mut out = String::new(); + let bytes = template.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let Some(open) = find_subseq(bytes, b"{{", i) else { + out.push_str(&template[i..]); + break; + }; + out.push_str(&template[i..open]); + let Some(close) = find_subseq(bytes, b"}}", open + 2) else { + // Unterminated tag — emit literally and stop. + out.push_str(&template[open..]); + break; + }; + let tag = template[open + 2..close].trim(); + let after = close + 2; + + if let Some(name) = tag.strip_prefix('#') { + let name = name.trim().to_string(); + let Some((inner_end, end_tag_end)) = find_section_end(bytes, after, &name) else { + // Unmatched section open — emit literally and stop. + out.push_str(&template[open..]); + break; + }; + let inner = &template[after..inner_end]; + match lookup(parent, item, &name) { + Some(Value::Items(items)) => { + for it in items { + out.push_str(&render_scoped(inner, parent, Some(it))); + } + } + Some(Value::Scalar(s)) if !s.is_empty() => { + out.push_str(&render_scoped(inner, parent, item)); + } + _ => {} + } + i = end_tag_end; + continue; + } + if tag.starts_with('/') { + // Stray close tag outside a section — emit literally. + out.push_str(&template[open..after]); + i = after; + continue; + } + let (raw, name) = if let Some(rest) = tag.strip_prefix('!') { + (true, rest.trim()) + } else { + (false, tag) + }; + match lookup(parent, item, name) { + Some(Value::Scalar(s)) => { + if raw { + out.push_str(s); + } else { + out.push_str(&yaml_scalar(s)); + } + } + _ => {} + } + i = after; + } + out +} + +fn lookup<'a>(parent: &'a Ctx, item: Option<&'a Ctx>, name: &str) -> Option<&'a Value> { + if let Some(it) = item { + if let Some(v) = it.get(name) { + return Some(v); + } + } + parent.get(name) +} + +/// Find `{{/name}}` balanced with any nested `{{#name}}` openings. +/// Returns (inner_end, end_tag_end) where inner_end is the byte +/// index of the `{{` of the closing tag and end_tag_end is one past +/// the closing `}}`. +fn find_section_end(bytes: &[u8], from: usize, name: &str) -> Option<(usize, usize)> { + let mut depth = 1usize; + let mut i = from; + while i < bytes.len() { + let open = find_subseq(bytes, b"{{", i)?; + let close = find_subseq(bytes, b"}}", open + 2)?; + let tag = std::str::from_utf8(&bytes[open + 2..close]).ok()?.trim(); + if let Some(n) = tag.strip_prefix('#') { + if n.trim() == name { + depth += 1; + } + } else if let Some(n) = tag.strip_prefix('/') { + if n.trim() == name { + depth -= 1; + if depth == 0 { + return Some((open, close + 2)); + } + } + } + i = close + 2; + } + None +} + +fn find_subseq(hay: &[u8], needle: &[u8], from: usize) -> Option { + if needle.is_empty() || from >= hay.len() || hay.len() < needle.len() { + return None; + } + hay[from..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|off| off + from) +} + +/// Quote `s` as a YAML double-quoted scalar. Always quotes so we +/// don't have to reason about special unquoted forms (numbers, +/// booleans, null, leading `-`, embedded `:`, etc.). +fn yaml_scalar(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn write_finding_md( + path: &Path, + f: &Finding, + id_to_tag: &std::collections::HashMap, +) -> Result<()> { + let mut m = String::new(); + m.push_str(&format!("# `{}` — {}\n\n", f.id, f.title)); + m.push_str(&format!( + "**Severity:** {} \n**Status:** {}\n\n", + severity_str(f.severity), + status_str(f.status) + )); + if let Some(ref ib) = f.introduced_by { + if !ib.sha.is_empty() { + if ib.subject.is_empty() { + m.push_str(&format!("**Introduced by:** `{}` \n", ib.sha)); + } else { + m.push_str(&format!( + "**Introduced by:** `{}` — {} \n", + ib.sha, ib.subject + )); + } + } + } + if let Some(ref t) = f.first_seen_task { + m.push_str(&format!("**First seen:** `{}` \n", t)); + } + if let Some(ref t) = f.last_updated_task { + m.push_str(&format!("**Last updated:** `{}` \n", t)); + } + if !f.related_finding_ids.is_empty() { + m.push_str("**Related:** "); + m.push_str( + &f.related_finding_ids + .iter() + .map(|id| match id_to_tag.get(id) { + Some(tag) => format!("[`{id}`](../{tag}/FINDING.md)"), + None => format!("`{id}`"), + }) + .collect::>() + .join(", "), + ); + m.push('\n'); + } + m.push('\n'); + + m.push_str("## Summary\n\n"); + m.push_str(&f.summary); + m.push_str("\n\n"); + + if let Some(ref md) = f.mechanism_detail { + if !md.is_empty() { + m.push_str("## Mechanism\n\n"); + m.push_str(md); + m.push_str("\n\n"); + } + } + + m.push_str("## Reproducer\n\n"); + m.push_str(&f.reproducer_sketch); + m.push_str("\n\n## Impact\n\n"); + m.push_str(&f.impact); + m.push_str("\n\n"); + + if let Some(ref fx) = f.fix_sketch { + if !fx.is_empty() { + m.push_str("## Fix sketch\n\n"); + m.push_str(fx); + m.push_str("\n\n"); + } + } + + if !f.open_questions.is_empty() { + m.push_str("## Open questions\n\n"); + for q in &f.open_questions { + m.push_str(&format!("- {q}\n")); + } + m.push('\n'); + } + + if !f.relevant_symbols.is_empty() { + m.push_str("## Relevant symbols\n\n"); + for s in &f.relevant_symbols { + m.push_str(&format!("- `{}` at `{}:{}`\n", s.name, s.filename, s.line)); + if !s.definition.is_empty() { + m.push_str(" ```\n"); + for line in s.definition.lines() { + m.push_str(" "); + m.push_str(line); + m.push('\n'); + } + m.push_str(" ```\n"); + } + } + m.push('\n'); + } + + if !f.relevant_file_sections.is_empty() { + m.push_str("## Relevant file sections\n\n"); + for sec in &f.relevant_file_sections { + m.push_str(&format!( + "### `{}` lines {}–{}\n\n", + sec.filename, sec.line_start, sec.line_end + )); + if !sec.content.is_empty() { + m.push_str("```\n"); + m.push_str(&sec.content); + if !sec.content.ends_with('\n') { + m.push('\n'); + } + m.push_str("```\n\n"); + } + } + } + + if !f.details.is_empty() { + m.push_str("## Task details\n\n"); + for d in &f.details { + render_detail(&mut m, d); + } + } + + std::fs::write(path, m).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +fn render_detail(out: &mut String, d: &FindingDetail) { + out.push_str(&format!("### `{}`\n\n", d.task)); + out.push_str(&d.analysis); + if !d.analysis.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); +} + +#[cfg(test)] +mod tests { + use super::*; + use kres_core::findings::{IntroducedBy, RelevantFileSection, RelevantSymbol}; + + fn finding_sample() -> Finding { + Finding { + id: "race_in_cq_ack".into(), + title: "Race in CQ ack".into(), + severity: Severity::High, + status: Status::Active, + relevant_symbols: vec![RelevantSymbol { + name: "cq_ack".into(), + filename: "drivers/net/x.c".into(), + line: 42, + definition: "void cq_ack(void) {}".into(), + }], + relevant_file_sections: vec![RelevantFileSection { + filename: "drivers/net/x.c".into(), + line_start: 40, + line_end: 50, + content: "void cq_ack(void) {\n}\n".into(), + }], + summary: "s".into(), + reproducer_sketch: "r".into(), + impact: "i".into(), + mechanism_detail: None, + fix_sketch: None, + open_questions: vec!["What about A?".into()], + first_seen_task: Some("t1".into()), + last_updated_task: Some("t2".into()), + related_finding_ids: vec!["rel_a".into()], + reactivate: false, + details: vec![], + introduced_by: None, + first_seen_at: None, + } + } + + fn git_sample() -> GitHead { + GitHead { + sha: "abc123".into(), + subject: "a \"quoted\" subject".into(), + } + } + + #[test] + fn sanitize_keeps_safe_chars_and_collapses_the_rest() { + assert_eq!(sanitize_tag("race-in-cq_ack"), "race-in-cq_ack"); + assert_eq!(sanitize_tag("foo/bar baz"), "foo_bar_baz"); + assert_eq!(sanitize_tag("///leading"), "leading"); + assert_eq!(sanitize_tag(""), "finding"); + } + + #[test] + fn unique_tag_appends_suffix_on_collision() { + let mut used = std::collections::HashSet::new(); + assert_eq!(unique_tag("a/b", &mut used), "a_b"); + assert_eq!(unique_tag("a b", &mut used), "a_b-2"); + assert_eq!(unique_tag("a!b", &mut used), "a_b-3"); + } + + #[test] + fn yaml_scalar_quotes_and_escapes() { + assert_eq!(yaml_scalar("plain"), "\"plain\""); + assert_eq!(yaml_scalar("a \"quoted\""), "\"a \\\"quoted\\\"\""); + assert_eq!(yaml_scalar("line1\nline2"), "\"line1\\nline2\""); + assert_eq!(yaml_scalar("back\\slash"), "\"back\\\\slash\""); + } + + #[test] + fn render_scalars_raw_and_quoted() { + let mut ctx: Ctx = BTreeMap::new(); + ctx.insert("id".into(), Value::Scalar("race_x".into())); + ctx.insert("severity".into(), Value::Scalar("high".into())); + let t = "id: {{id}}\nseverity: {{!severity}}\n"; + assert_eq!(render(t, &ctx), "id: \"race_x\"\nseverity: high\n"); + } + + #[test] + fn render_section_skipped_when_missing_or_empty() { + let ctx: Ctx = BTreeMap::new(); + let t = "a\n{{#has_x}}inside\n{{/has_x}}b\n"; + assert_eq!(render(t, &ctx), "a\nb\n"); + } + + #[test] + fn render_list_section_iterates_items() { + let mut ctx: Ctx = BTreeMap::new(); + ctx.insert("has_list".into(), Value::Scalar("1".into())); + let items = vec![ + { + let mut m = BTreeMap::new(); + m.insert("item".into(), Value::Scalar("a".into())); + m + }, + { + let mut m = BTreeMap::new(); + m.insert("item".into(), Value::Scalar("b".into())); + m + }, + ]; + ctx.insert("list".into(), Value::Items(items)); + let t = "{{#has_list}}list:\n{{#list}} - {{item}}\n{{/list}}{{/has_list}}"; + assert_eq!(render(t, &ctx), "list:\n - \"a\"\n - \"b\"\n"); + } + + #[test] + fn embedded_template_renders_against_real_finding() { + let out = render( + METADATA_TEMPLATE, + &build_context(&finding_sample(), &git_sample()), + ); + assert!(out.contains("id: \"race_in_cq_ack\"")); + assert!(out.contains("severity: high\n")); + assert!(out.contains("status: active\n")); + assert!(out.contains("sha: \"abc123\"")); + // double-quotes inside the commit subject must be escaped. + assert!(out.contains("subject: \"a \\\"quoted\\\" subject\"")); + assert!(out.contains("first_seen_task: \"t1\"")); + assert!(out.contains("related_finding_ids:\n - \"rel_a\"\n")); + assert!(out.contains("relevant_symbols:\n")); + assert!(out.contains(" line: 42\n")); + assert!(out.contains("relevant_file_sections:\n")); + assert!(out.contains(" line_start: 40\n")); + assert!(out.contains("open_questions:\n - \"What about A?\"\n")); + } + + fn tmp_dir(nonce: &str) -> std::path::PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let mut p = std::env::temp_dir(); + p.push(format!( + "kres-export-test-{}-{}-{:x}", + nonce, + std::process::id(), + nanos + )); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn top_level_scalar_parses_quoted_and_raw() { + let y = "id: \"race_x\"\nseverity: high\n nested: ignore\nstatus: active\n"; + assert_eq!(top_level_scalar(y, "id").as_deref(), Some("race_x")); + assert_eq!(top_level_scalar(y, "severity").as_deref(), Some("high")); + assert_eq!(top_level_scalar(y, "status").as_deref(), Some("active")); + assert_eq!(top_level_scalar(y, "nested"), None, "indented line ignored"); + assert_eq!(top_level_scalar(y, "missing"), None); + } + + #[test] + fn top_level_scalar_unquotes_escapes() { + let y = "title: \"a \\\"quoted\\\" title\"\n"; + assert_eq!( + top_level_scalar(y, "title").as_deref(), + Some("a \"quoted\" title") + ); + } + + #[test] + fn export_index_sorts_by_severity_then_date_then_id() { + let dir = tmp_dir("export-index"); + let write = |tag: &str, body: &str| { + let d = dir.join(tag); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("metadata.yaml"), body).unwrap(); + }; + write( + "b_newer_high", + "id: \"b\"\ntitle: \"newer high\"\nseverity: high\nstatus: active\ndate: \"2026-04-24T10:00:00Z\"\n", + ); + write( + "a_older_high", + "id: \"a\"\ntitle: \"older high\"\nseverity: high\nstatus: active\ndate: \"2026-04-20T10:00:00Z\"\n", + ); + write( + "c_no_date_high", + "id: \"c\"\ntitle: \"undated high\"\nseverity: high\nstatus: active\n", + ); + write( + "d_medium", + "id: \"d\"\ntitle: \"medium\"\nseverity: medium\nstatus: active\ndate: \"2026-01-01T00:00:00Z\"\n", + ); + write( + "e_low", + "id: \"e\"\ntitle: \"low\"\nseverity: low\nstatus: invalidated\ndate: \"2026-02-01T00:00:00Z\"\n", + ); + let out = run_export_index(&dir).unwrap(); + let body = std::fs::read_to_string(&out).unwrap(); + // Severity desc, oldest-first within a tier, undated at the + // bottom of its tier. + let order = [ + "[`a`](a_older_high/FINDING.md)", + "[`b`](b_newer_high/FINDING.md)", + "[`c`](c_no_date_high/FINDING.md)", + "[`d`](d_medium/FINDING.md)", + "[`e`](e_low/FINDING.md)", + ]; + let mut cursor = 0usize; + for want in order { + let hit = body[cursor..] + .find(want) + .unwrap_or_else(|| panic!("ordering wrong; missing {want} after byte {cursor}\n{body}")); + cursor += hit + want.len(); + } + // Histogram line is present. + assert!(body.contains("3 high, 1 medium, 1 low"), "{body}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn finding_md_related_emits_markdown_link_for_known_ids() { + let mut f = finding_sample(); + f.related_finding_ids = vec!["present/id".into(), "absent_id".into()]; + let mut id_to_tag = std::collections::HashMap::new(); + // "present/id" sanitises to "present_id"; "absent_id" isn't + // part of the export so no entry in the map → plain code. + id_to_tag.insert("present/id".to_string(), "present_id".to_string()); + let dir = tmp_dir("related-links"); + let path = dir.join("FINDING.md"); + write_finding_md(&path, &f, &id_to_tag).unwrap(); + let body = std::fs::read_to_string(&path).unwrap(); + assert!( + body.contains("[`present/id`](../present_id/FINDING.md)"), + "missing link: {body}" + ); + // Absent id falls through to plain code formatting. + assert!(body.contains(", `absent_id`"), "missing fallback: {body}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn embedded_template_renders_introduced_by_when_set() { + let mut f = finding_sample(); + f.introduced_by = Some(IntroducedBy { + sha: "deadbeef".into(), + subject: "subsys: a regression".into(), + }); + let out = render(METADATA_TEMPLATE, &build_context(&f, &git_sample())); + assert!(out.contains("introduced_by:\n")); + assert!(out.contains(" sha: \"deadbeef\"\n")); + assert!(out.contains(" subject: \"subsys: a regression\"\n")); + } + + #[test] + fn embedded_template_introduced_by_sha_only() { + let mut f = finding_sample(); + f.introduced_by = Some(IntroducedBy { + sha: "cafebabe".into(), + subject: "".into(), + }); + let out = render(METADATA_TEMPLATE, &build_context(&f, &git_sample())); + assert!(out.contains(" sha: \"cafebabe\"\n")); + // Exactly one `subject:` line from the git block — none from + // introduced_by when its subject is empty. + let subject_lines = out.matches(" subject:").count(); + assert_eq!(subject_lines, 1, "only the git subject line should appear"); + } + + #[test] + fn embedded_template_omits_introduced_by_when_unset() { + let out = render( + METADATA_TEMPLATE, + &build_context(&finding_sample(), &git_sample()), + ); + assert!(!out.contains("introduced_by")); + } + + #[test] + fn embedded_template_omits_empty_sections() { + let mut f = finding_sample(); + f.relevant_symbols.clear(); + f.relevant_file_sections.clear(); + f.open_questions.clear(); + f.related_finding_ids.clear(); + f.first_seen_task = None; + f.last_updated_task = None; + let out = render(METADATA_TEMPLATE, &build_context(&f, &git_sample())); + assert!(!out.contains("relevant_symbols:")); + assert!(!out.contains("relevant_file_sections:")); + assert!(!out.contains("open_questions:")); + assert!(!out.contains("related_finding_ids:")); + assert!(!out.contains("first_seen_task:")); + assert!(!out.contains("last_updated_task:")); + // Required scalars still render. + assert!(out.contains("id: \"race_in_cq_ack\"")); + assert!(out.contains("severity: high")); + } +} diff --git a/kres-repl/src/lib.rs b/kres-repl/src/lib.rs index 706ab83..0cb6388 100644 --- a/kres-repl/src/lib.rs +++ b/kres-repl/src/lib.rs @@ -20,6 +20,7 @@ //! (bugs.md#M8 UX, adapted). pub mod commands; +pub mod export; pub mod report; pub mod session; pub mod settings; @@ -27,6 +28,7 @@ pub mod status; pub mod summary; pub use commands::{parse_command, Command}; +pub use export::{run_export, run_export_index, ExportInputs}; pub use report::{append_task_section, render_findings_markdown, write_findings_to_file}; pub use session::{build_orchestrator, ReplConfig, Session}; pub use settings::{pick_model, ModelRole, Settings}; diff --git a/kres-repl/src/report.rs b/kres-repl/src/report.rs index 9cb41f7..7104dad 100644 --- a/kres-repl/src/report.rs +++ b/kres-repl/src/report.rs @@ -1,7 +1,7 @@ //! Markdown report writer. //! //! Produces a human-friendly report of the current findings list. -//! Groups by severity (critical → high → medium → low), renders +//! Groups by severity (high → medium → low), renders //! mechanism_detail / fix_sketch / open_questions when present. use std::io::Write; @@ -21,12 +21,7 @@ pub fn render_findings_markdown(findings: &[Finding]) -> String { out.push_str(&severity_histogram(findings)); out.push('\n'); - for sev in [ - Severity::Critical, - Severity::High, - Severity::Medium, - Severity::Low, - ] { + for sev in [Severity::High, Severity::Medium, Severity::Low] { let bucket: Vec<&Finding> = findings.iter().filter(|f| f.severity == sev).collect(); if bucket.is_empty() { continue; @@ -40,15 +35,14 @@ pub fn render_findings_markdown(findings: &[Finding]) -> String { } fn severity_histogram(findings: &[Finding]) -> String { - let (c, h, m, l) = findings + let (h, m, l) = findings .iter() - .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity { - Severity::Critical => (c + 1, h, m, l), - Severity::High => (c, h + 1, m, l), - Severity::Medium => (c, h, m + 1, l), - Severity::Low => (c, h, m, l + 1), + .fold((0, 0, 0), |(h, m, l), f| match f.severity { + Severity::High => (h + 1, m, l), + Severity::Medium => (h, m + 1, l), + Severity::Low => (h, m, l + 1), }); - format!("- {} critical, {} high, {} medium, {} low\n", c, h, m, l) + format!("- {} high, {} medium, {} low\n", h, m, l) } fn render_finding(out: &mut String, f: &Finding) { @@ -178,6 +172,8 @@ mod tests { related_finding_ids: vec!["other".into()], reactivate: false, details: vec![], + introduced_by: None, + first_seen_at: None, } } @@ -192,14 +188,14 @@ mod tests { let findings = vec![ finding("a", Severity::Low), finding("b", Severity::High), - finding("c", Severity::Critical), + finding("c", Severity::Medium), ]; let md = render_findings_markdown(&findings); - let crit_pos = md.find("## Critical").unwrap(); let high_pos = md.find("## High").unwrap(); + let med_pos = md.find("## Medium").unwrap(); let low_pos = md.find("## Low").unwrap(); - assert!(crit_pos < high_pos); - assert!(high_pos < low_pos); + assert!(high_pos < med_pos); + assert!(med_pos < low_pos); } #[test] diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 291326d..4474810 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -2233,19 +2233,17 @@ impl Session { println!("(no findings yet)"); return; } - let (hi, med, lo, crit) = findings.iter().fold((0, 0, 0, 0), |(h, m, l, c), f| { + let (hi, med, lo) = findings.iter().fold((0, 0, 0), |(h, m, l), f| { use kres_core::findings::Severity::*; match f.severity { - Critical => (h, m, l, c + 1), - High => (h + 1, m, l, c), - Medium => (h, m + 1, l, c), - Low => (h, m, l + 1, c), + High => (h + 1, m, l), + Medium => (h, m + 1, l), + Low => (h, m, l + 1), } }); println!( - "{} findings: {} critical, {} high, {} medium, {} low", + "{} findings: {} high, {} medium, {} low", findings.len(), - crit, hi, med, lo diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index 8031c3c..42c5c96 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -867,7 +867,6 @@ fn combine_system_prompt(markdown: bool) -> String { /// Rank used by the severity sort. Higher = more severe. fn severity_rank(s: Severity) -> u8 { match s { - Severity::Critical => 4, Severity::High => 3, Severity::Medium => 2, Severity::Low => 1, @@ -994,6 +993,8 @@ mod tests { }) .collect(), reactivate: false, + introduced_by: None, + first_seen_at: None, } } @@ -1001,15 +1002,15 @@ mod tests { fn severity_sort_desc_with_stable_within_band() { let findings = [ f("a", Severity::Low, Status::Active, vec![]), - f("b", Severity::Critical, Status::Active, vec![]), + f("b", Severity::High, Status::Active, vec![]), f("c", Severity::Medium, Status::Active, vec![]), - f("d", Severity::Critical, Status::Active, vec![]), + f("d", Severity::High, Status::Active, vec![]), f("e", Severity::High, Status::Active, vec![]), ]; let mut got: Vec = findings.to_vec(); got.sort_by(|a, b| severity_rank(b.severity).cmp(&severity_rank(a.severity))); let ids: Vec<&str> = got.iter().map(|x| x.id.as_str()).collect(); - // Critical (b,d) first (input order), then High (e), Medium (c), Low (a). + // High (b, d, e) first in input order, then Medium (c), Low (a). assert_eq!(ids, vec!["b", "d", "e", "c", "a"]); } diff --git a/kres/src/main.rs b/kres/src/main.rs index 0edb705..9608eec 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -190,6 +190,26 @@ struct ReplArgs { #[arg(long, default_value_t = false)] summary_markdown: bool, + /// Export every finding from `findings.json` as a per-finding + /// folder under DIR. Each entry becomes `DIR//` with a + /// `meta.yaml` (id, severity, workspace git sha/subject, cross + /// references) and a `FINDING.md` carrying the full body + /// (summary, mechanism, reproducer, impact, fix sketch, open + /// questions, per-task analysis). Inputs honour --results / + /// --findings the same way --summary does. Exits without + /// starting the REPL. + #[arg(long, value_name = "DIR")] + export: Option, + + /// Walk every `/metadata.yaml` under DIR (the output of a + /// prior `--export`) and write `DIR/INDEX.md` — a single + /// markdown index sorted by severity (high → low) and then by + /// the `date` field (oldest first, so long-standing bugs stay + /// visible at the top of each severity band). Exits without + /// starting the REPL; no findings.json is consulted. + #[arg(long, value_name = "DIR")] + export_index: Option, + /// Override the summary template path for --summary / /// --summary-markdown. Accepted by `/summary` too. When /// omitted, kres reads `~/.kres/commands/summary.md` (or @@ -499,10 +519,14 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // template and filename further down. let summary_mode = args.summary || args.summary_markdown; let markdown = args.summary_markdown; + let export_mode = args.export.is_some(); + let export_index_mode = args.export_index.is_some(); - // In --summary mode we avoid creating a fresh session directory - // because the operator points at an existing run's artifacts. - let results_dir = match (args.results.clone(), summary_mode) { + // In --summary / --export mode we avoid creating a fresh session + // directory because the operator points at an existing run's + // artifacts. + let standalone = summary_mode || export_mode || export_index_mode; + let results_dir = match (args.results.clone(), standalone) { (Some(d), _) => d, (None, true) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), (None, false) => { @@ -605,6 +629,46 @@ async fn run_repl(args: ReplArgs) -> Result<()> { return Ok(()); } + // --- --export DIR: per-finding folder tree ------------------- + // Iterates findings.json (honouring --results / --findings), + // writes DIR//meta.yaml + DIR//FINDING.md for every + // finding, then exits. No REPL, no MCP, no orchestrator. + if let Some(ref export_dir) = args.export { + let findings_path = match findings_base.as_ref() { + Some(p) if p.exists() => p.clone(), + Some(p) => { + return Err(anyhow::anyhow!( + "--export: findings file {} does not exist", + p.display() + )); + } + None => { + return Err(anyhow::anyhow!( + "--export: no findings path configured (pass --findings or --results)" + )); + } + }; + eprintln!("--export: findings = {}", findings_path.display()); + eprintln!("--export: output = {}", export_dir.display()); + kres_repl::run_export(kres_repl::ExportInputs { + findings_path, + output_dir: export_dir.clone(), + workspace: args.workspace.clone(), + }) + .await?; + return Ok(()); + } + + // --- --export-index DIR: walk a prior --export dir ------------ + // Reads every /metadata.yaml under DIR, sorts by severity + // then date, writes DIR/INDEX.md, and exits. + if let Some(ref index_dir) = args.export_index { + eprintln!("--export-index: dir = {}", index_dir.display()); + let out = kres_repl::run_export_index(index_dir)?; + eprintln!("--export-index: wrote = {}", out.display()); + return Ok(()); + } + // --- Announce resolved paths ----------------------------------- for (label, p) in [ ("fast-agent", fast_agent.as_ref()), From 7b08ccd326ea0e11d7ee47580cb82a202bd275b7 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Fri, 24 Apr 2026 12:28:02 -0700 Subject: [PATCH 52/76] tui: switch the interactive REPL to ratatui Migrate the REPL's UI from the rustyline prompt + DECSTBM status bar to ratatui. The rustyline path remains reachable via --no-tui as an escape hatch, and --stdio stays unchanged for redirected output. Signed-off-by: Chris Mason --- Cargo.lock | 295 +++- kres-agents/src/embedded_prompts.rs | 14 +- kres-agents/src/goal.rs | 19 +- kres-agents/src/promote.rs | 5 +- kres-core/src/findings.rs | 36 +- kres-core/src/io.rs | 45 +- kres-llm/src/client.rs | 8 +- kres-repl/Cargo.toml | 6 + kres-repl/src/export.rs | 76 +- kres-repl/src/lib.rs | 1 + kres-repl/src/session.rs | 417 +++--- kres-repl/src/summary.rs | 91 +- kres-repl/src/tui.rs | 1932 +++++++++++++++++++++++++++ kres/src/main.rs | 25 +- 14 files changed, 2696 insertions(+), 274 deletions(-) create mode 100644 kres-repl/src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index b582423..c5419b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -141,6 +147,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -238,6 +259,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -270,6 +305,65 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dirs" version = "5.0.1" @@ -302,6 +396,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "endian-type" version = "0.1.2" @@ -354,7 +454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix", + "rustix 1.1.4", "windows-sys 0.52.0", ] @@ -523,6 +623,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -757,6 +859,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -790,6 +898,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -812,6 +942,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -935,12 +1074,14 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "crossterm", "dirs", "kres-agents", "kres-core", "kres-llm", "kres-mcp", "libc", + "ratatui", "rustyline", "serde", "serde_json", @@ -977,6 +1118,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1004,6 +1151,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1048,6 +1204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1142,6 +1299,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1306,6 +1469,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1404,6 +1588,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1413,7 +1610,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1475,7 +1672,7 @@ dependencies = [ "nix", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "utf8parse", "windows-sys 0.52.0", ] @@ -1574,6 +1771,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1618,12 +1836,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1668,9 +1914,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1928,12 +2174,29 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2152,6 +2415,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/kres-agents/src/embedded_prompts.rs b/kres-agents/src/embedded_prompts.rs index 8c24a65..03b9ccc 100644 --- a/kres-agents/src/embedded_prompts.rs +++ b/kres-agents/src/embedded_prompts.rs @@ -130,8 +130,8 @@ mod tests { // or re-run setup.sh. let legacy = lookup("slow-code-agent.system.md") .expect("legacy basename must resolve via translation"); - let new = lookup("slow-code-agent-audit.system.md") - .expect("new basename must resolve directly"); + let new = + lookup("slow-code-agent-audit.system.md").expect("new basename must resolve directly"); assert_eq!(legacy, new, "translation must return identical body"); } @@ -139,8 +139,14 @@ mod tests { fn translate_legacy_passes_through_unknown_basenames() { // Non-legacy basenames must not be rewritten — the shim is // opt-in per entry. - assert_eq!(translate_legacy_basename("todo-agent.system.md"), "todo-agent.system.md"); - assert_eq!(translate_legacy_basename("does-not-exist.md"), "does-not-exist.md"); + assert_eq!( + translate_legacy_basename("todo-agent.system.md"), + "todo-agent.system.md" + ); + assert_eq!( + translate_legacy_basename("does-not-exist.md"), + "does-not-exist.md" + ); } #[test] diff --git a/kres-agents/src/goal.rs b/kres-agents/src/goal.rs index 1c32574..bc95b55 100644 --- a/kres-agents/src/goal.rs +++ b/kres-agents/src/goal.rs @@ -600,10 +600,8 @@ mod tests { // here so a future change to the deserialize policy // (e.g. tolerating unknown modes) doesn't silently break // the outer fallback. - let r: Option = extract_json_with_key( - r#"{"goal": "check x", "mode": "investigation"}"#, - "goal", - ); + let r: Option = + extract_json_with_key(r#"{"goal": "check x", "mode": "investigation"}"#, "goal"); assert!(r.is_none(), "unparseable mode collapses entire reply"); } @@ -632,7 +630,10 @@ mod tests { let plan = sample_plan(); let r = build_define_goal_request("tree.c CPU hotplug", Some(&plan)); let obj = r.as_object().unwrap(); - assert_eq!(obj.get("task").and_then(|v| v.as_str()), Some("define_goal")); + assert_eq!( + obj.get("task").and_then(|v| v.as_str()), + Some("define_goal") + ); let plan_v = obj.get("plan").expect("plan should be embedded"); assert_eq!( plan_v.get("prompt").and_then(|v| v.as_str()), @@ -792,12 +793,8 @@ mod tests { fn build_plan_titleless_slug_falls_back_to_step_n() { // A title that contains no slug-able characters falls back // to `step-` so the plan is never left with an empty id. - let plan = build_plan_from_raw( - vec![step_raw("", "!!!")], - "prompt", - "goal", - TaskMode::Audit, - ); + let plan = + build_plan_from_raw(vec![step_raw("", "!!!")], "prompt", "goal", TaskMode::Audit); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].id, "step-1"); } diff --git a/kres-agents/src/promote.rs b/kres-agents/src/promote.rs index e562763..dc2ae82 100644 --- a/kres-agents/src/promote.rs +++ b/kres-agents/src/promote.rs @@ -376,6 +376,9 @@ mod tests { ) .await .unwrap(); - assert!(out.is_empty(), "cancel path must return an empty extras list"); + assert!( + out.is_empty(), + "cancel path must return an empty extras list" + ); } } diff --git a/kres-core/src/findings.rs b/kres-core/src/findings.rs index 5074b95..0dd3c6b 100644 --- a/kres-core/src/findings.rs +++ b/kres-core/src/findings.rs @@ -372,11 +372,7 @@ impl FindingsStore { /// NEVER forwarded to another LLM. Agents see findings via /// [`redact_findings_for_agent`] on `&[Finding]`; the /// file-level `task_prose` list never enters an agent payload. - pub async fn append_task_prose( - &self, - task: &str, - prose: &str, - ) -> Result<(), FindingsError> { + pub async fn append_task_prose(&self, task: &str, prose: &str) -> Result<(), FindingsError> { if prose.is_empty() { return Ok(()); } @@ -525,9 +521,7 @@ fn merge_into(existing: &mut Finding, incoming: &Finding, task_id: Option<&str>) existing.status = Status::Active; changed = true; } - } else if incoming.status == Status::Invalidated - && existing.status != Status::Invalidated - { + } else if incoming.status == Status::Invalidated && existing.status != Status::Invalidated { existing.status = Status::Invalidated; changed = true; } @@ -1033,7 +1027,10 @@ mod tests { reactive.status = Status::Active; reactive.reactivate = true; reactive.summary = "new evidence reverses it".into(); - let rep3 = store.apply_delta(&[reactive], Some("t3"), None).await.unwrap(); + let rep3 = store + .apply_delta(&[reactive], Some("t3"), None) + .await + .unwrap(); // The reactivation must be counted as such — not folded into // the generic "updated" bucket. assert_eq!(rep3.reactivated, 1); @@ -1093,8 +1090,14 @@ mod tests { let snap = store.snapshot().await; assert!(snap[0].summary.starts_with("a detailed")); assert!(snap[0].impact.starts_with("detailed")); - assert_eq!(snap[0].mechanism_detail.as_deref(), Some("rich mechanism context")); - assert_eq!(snap[0].fix_sketch.as_deref(), Some("rich fix with file:line anchors")); + assert_eq!( + snap[0].mechanism_detail.as_deref(), + Some("rich mechanism context") + ); + assert_eq!( + snap[0].fix_sketch.as_deref(), + Some("rich fix with file:line anchors") + ); // last_updated_task still advances even when prose didn't win. assert_eq!(snap[0].last_updated_task.as_deref(), Some("t2")); std::fs::remove_dir_all(&dir).ok(); @@ -1112,7 +1115,10 @@ mod tests { rich.summary = "much more detailed summary with concrete specifics".into(); store.apply_delta(&[rich], Some("t2"), None).await.unwrap(); let snap = store.snapshot().await; - assert_eq!(snap[0].summary, "much more detailed summary with concrete specifics"); + assert_eq!( + snap[0].summary, + "much more detailed summary with concrete specifics" + ); std::fs::remove_dir_all(&dir).ok(); } @@ -1318,7 +1324,11 @@ mod tests { { let store = FindingsStore::new(&base).await.unwrap(); store - .apply_delta(&[sample_finding("a"), sample_finding("b")], Some("t1"), None) + .apply_delta( + &[sample_finding("a"), sample_finding("b")], + Some("t1"), + None, + ) .await .unwrap(); store.db.flush().await; diff --git a/kres-core/src/io.rs b/kres-core/src/io.rs index 7c14a41..8ea2028 100644 --- a/kres-core/src/io.rs +++ b/kres-core/src/io.rs @@ -15,26 +15,54 @@ //! handler is installed (non-REPL contexts like `kres turn` or //! setup-phase logging), messages fall through to `eprintln!`. -use std::sync::OnceLock; +use std::sync::{OnceLock, RwLock}; /// The sink receives each line as a String. The REPL wraps an /// `ExternalPrinter` behind this. Non-REPL callers never install a /// handler and their messages go to stderr via the fallback. pub type PrinterFn = Box; -static PRINTER: OnceLock = OnceLock::new(); +/// The printer slot is an `RwLock>` rather than a plain +/// `OnceLock` so startup code can install a cheap fallback (e.g. +/// a stdout writer) early and replace it with a fancier sink once +/// the REPL finishes booting (rustyline's ExternalPrinter, the TUI +/// scrollback). Without replacement, messages emitted *during* +/// bring-up — banner, initial-prompt notice, lens list — either +/// race the install or fall through to `eprintln!` and miss the +/// real sink entirely. +fn slot() -> &'static RwLock> { + static SLOT: OnceLock>> = OnceLock::new(); + SLOT.get_or_init(|| RwLock::new(None)) +} -/// Install the global printer sink. Idempotent — subsequent calls -/// after the first are ignored (returns `Err` with the rejected -/// handler). Typically invoked once from the REPL startup. +/// Install the global printer sink. Install-if-absent semantics: +/// succeeds when the slot is empty, returns `Err(f)` if another +/// printer is already installed. Preserves the original +/// call-once contract for tests and non-REPL entry points that +/// shouldn't stomp on an in-place handler. pub fn install_printer(f: PrinterFn) -> Result<(), PrinterFn> { - PRINTER.set(f) + let mut g = slot().write().unwrap(); + if g.is_some() { + return Err(f); + } + *g = Some(f); + Ok(()) +} + +/// Replace the installed printer unconditionally, returning any +/// previously-installed handler. Used by startup sequences that +/// need to swap a bootstrap printer (stdout fallback) for the +/// real one (ExternalPrinter / TUI scrollback) once the real sink +/// is ready. +pub fn replace_printer(f: PrinterFn) -> Option { + let mut g = slot().write().unwrap(); + g.replace(f) } /// Has a printer been installed? Useful for call sites that want to /// skip work when there's no REPL listening. pub fn has_printer() -> bool { - PRINTER.get().is_some() + slot().read().unwrap().is_some() } /// Route a single line through the installed printer, falling back @@ -42,7 +70,8 @@ pub fn has_printer() -> bool { /// newline — the sink appends one. pub fn async_println(line: impl Into) { let s = line.into(); - match PRINTER.get() { + let g = slot().read().unwrap(); + match g.as_ref() { Some(f) => f(s), None => eprintln!("{s}"), } diff --git a/kres-llm/src/client.rs b/kres-llm/src/client.rs index 9e0141d..3710bd9 100644 --- a/kres-llm/src/client.rs +++ b/kres-llm/src/client.rs @@ -398,13 +398,7 @@ impl Client { Err(e) => { if attempt < MAX_RETRIES && is_transport_retryable(&e) { let wait = backoff_duration(attempt); - log_transport_retry( - "messages_streaming", - attempt, - MAX_RETRIES, - &e, - wait, - ); + log_transport_retry("messages_streaming", attempt, MAX_RETRIES, &e, wait); tokio::time::sleep(wait).await; continue; } diff --git a/kres-repl/Cargo.toml b/kres-repl/Cargo.toml index 35c7985..4c87e5b 100644 --- a/kres-repl/Cargo.toml +++ b/kres-repl/Cargo.toml @@ -21,6 +21,12 @@ chrono = { workspace = true } dirs = { workspace = true } rustyline = "14" libc = "0.2" +# TUI scaffolding (stage 1 of the ratatui migration). Opt-in via +# `--tui`; the default REPL path still uses rustyline + the DECSTBM +# status line. `crossterm` is ratatui's default backend and is the +# source of the keyboard/resize event stream the TUI polls. +ratatui = "0.29" +crossterm = "0.28" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/kres-repl/src/export.rs b/kres-repl/src/export.rs index 83c81df..3102959 100644 --- a/kres-repl/src/export.rs +++ b/kres-repl/src/export.rs @@ -125,9 +125,7 @@ pub fn run_export_index(dir: &Path) -> Result { )); } let mut rows: Vec = Vec::new(); - for entry in std::fs::read_dir(dir) - .with_context(|| format!("reading {}", dir.display()))? - { + for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; @@ -142,11 +140,8 @@ pub fn run_export_index(dir: &Path) -> Result { tag: entry.file_name().to_string_lossy().into_owned(), id: top_level_scalar(&yaml, "id").unwrap_or_default(), title: top_level_scalar(&yaml, "title").unwrap_or_default(), - severity: parse_severity( - top_level_scalar(&yaml, "severity").as_deref().unwrap_or(""), - ), - status: top_level_scalar(&yaml, "status") - .unwrap_or_else(|| "active".to_string()), + severity: parse_severity(top_level_scalar(&yaml, "severity").as_deref().unwrap_or("")), + status: top_level_scalar(&yaml, "status").unwrap_or_else(|| "active".to_string()), date: top_level_scalar(&yaml, "date"), }); } @@ -217,10 +212,7 @@ fn top_level_scalar(yaml: &str, key: &str) -> Option { continue; }; let rest = rest.trim(); - if let Some(inner) = rest - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - { + if let Some(inner) = rest.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { return Some(unquote_yaml(inner)); } return Some(rest.to_string()); @@ -260,12 +252,14 @@ fn render_index(rows: &[IndexRow]) -> String { out.push_str("(no findings)\n"); return out; } - let (h, m, l, u) = rows.iter().fold((0, 0, 0, 0), |(h, m, l, u), r| match r.severity { - Some(Severity::High) => (h + 1, m, l, u), - Some(Severity::Medium) => (h, m + 1, l, u), - Some(Severity::Low) => (h, m, l + 1, u), - None => (h, m, l, u + 1), - }); + let (h, m, l, u) = rows + .iter() + .fold((0, 0, 0, 0), |(h, m, l, u), r| match r.severity { + Some(Severity::High) => (h + 1, m, l, u), + Some(Severity::Medium) => (h, m + 1, l, u), + Some(Severity::Low) => (h, m, l + 1, u), + None => (h, m, l, u + 1), + }); out.push_str(&format!( "{} finding(s): {} high, {} medium, {} low", rows.len(), @@ -313,7 +307,10 @@ fn escape_md_table_cell(s: &str) -> String { /// `~/.kres/prompts/` so we don't crowd the slash-commands namespace. fn load_metadata_template() -> String { if let Some(home) = dirs::home_dir() { - let p = home.join(".kres").join("prompts").join("export-metadata.yaml"); + let p = home + .join(".kres") + .join("prompts") + .join("export-metadata.yaml"); if let Ok(s) = std::fs::read_to_string(&p) { if !s.trim().is_empty() { return s; @@ -385,12 +382,7 @@ fn run_git(workspace: &Path, args: &[&str]) -> Option { } } -fn write_metadata_yaml( - path: &Path, - f: &Finding, - git: &GitHead, - template: &str, -) -> Result<()> { +fn write_metadata_yaml(path: &Path, f: &Finding, git: &GitHead, template: &str) -> Result<()> { let ctx = build_context(f, git); let body = render(template, &ctx); std::fs::write(path, body).with_context(|| format!("writing {}", path.display()))?; @@ -442,7 +434,10 @@ fn build_context(f: &Finding, git: &GitHead) -> Ctx { let mut c: Ctx = BTreeMap::new(); c.insert("id".into(), Value::Scalar(f.id.clone())); c.insert("title".into(), Value::Scalar(f.title.clone())); - c.insert("severity".into(), Value::Scalar(severity_str(f.severity).into())); + c.insert( + "severity".into(), + Value::Scalar(severity_str(f.severity).into()), + ); c.insert("status".into(), Value::Scalar(status_str(f.status).into())); c.insert("git_sha".into(), Value::Scalar(git.sha.clone())); c.insert("git_subject".into(), Value::Scalar(git.subject.clone())); @@ -466,7 +461,10 @@ fn build_context(f: &Finding, git: &GitHead) -> Ctx { c.insert("has_introduced_by".into(), Value::Scalar("1".into())); c.insert("introduced_by_sha".into(), Value::Scalar(ib.sha.clone())); if !ib.subject.is_empty() { - c.insert("has_introduced_by_subject".into(), Value::Scalar("1".into())); + c.insert( + "has_introduced_by_subject".into(), + Value::Scalar("1".into()), + ); c.insert( "introduced_by_subject".into(), Value::Scalar(ib.subject.clone()), @@ -512,7 +510,10 @@ fn build_context(f: &Finding, git: &GitHead) -> Ctx { c.insert("relevant_symbols".into(), Value::Items(items)); } if !f.relevant_file_sections.is_empty() { - c.insert("has_relevant_file_sections".into(), Value::Scalar("1".into())); + c.insert( + "has_relevant_file_sections".into(), + Value::Scalar("1".into()), + ); let items = f .relevant_file_sections .iter() @@ -601,15 +602,12 @@ fn render_scoped(template: &str, parent: &Ctx, item: Option<&Ctx>) -> String { } else { (false, tag) }; - match lookup(parent, item, name) { - Some(Value::Scalar(s)) => { - if raw { - out.push_str(s); - } else { - out.push_str(&yaml_scalar(s)); - } + if let Some(Value::Scalar(s)) = lookup(parent, item, name) { + if raw { + out.push_str(s); + } else { + out.push_str(&yaml_scalar(s)); } - _ => {} } i = after; } @@ -1024,9 +1022,9 @@ mod tests { ]; let mut cursor = 0usize; for want in order { - let hit = body[cursor..] - .find(want) - .unwrap_or_else(|| panic!("ordering wrong; missing {want} after byte {cursor}\n{body}")); + let hit = body[cursor..].find(want).unwrap_or_else(|| { + panic!("ordering wrong; missing {want} after byte {cursor}\n{body}") + }); cursor += hit + want.len(); } // Histogram line is present. diff --git a/kres-repl/src/lib.rs b/kres-repl/src/lib.rs index 0cb6388..607d048 100644 --- a/kres-repl/src/lib.rs +++ b/kres-repl/src/lib.rs @@ -26,6 +26,7 @@ pub mod session; pub mod settings; pub mod status; pub mod summary; +pub mod tui; pub use commands::{parse_command, Command}; pub use export::{run_export, run_export_index, ExportInputs}; diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 4474810..48aef66 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -57,6 +57,12 @@ pub struct ReplConfig { /// When true, skip the persistent status line (no DECSTBM scroll /// region). Useful for dumb terminals / pipes / finicky muxers. pub stdio: bool, + /// Opt into the ratatui TUI (stage 1 of the prompt-line + /// migration). When set, [`Session::run`] owns the terminal via + /// crossterm instead of rustyline. `stdio` takes precedence — + /// `--stdio --tui` still uses the plain path so output + /// redirection keeps working. + pub tui: bool, /// Root for coding-mode file output. Coding tasks emit a /// `code_output` array whose paths are relative; the reaper /// writes them under this directory (`/` — @@ -84,6 +90,7 @@ impl Default for ReplConfig { results_dir: None, template_path: None, stdio: false, + tui: false, workspace: PathBuf::from("."), persist_path: None, } @@ -412,12 +419,8 @@ impl Session { last_analysis: Arc::new(tokio::sync::Mutex::new(None)), pending_bootstrap: findings, logger: None, - task_goals: Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), - task_prompts: Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), + task_goals: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + task_prompts: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), accumulated: Arc::new(tokio::sync::Mutex::new(Vec::new())), deferred: Arc::new(tokio::sync::Mutex::new(Vec::new())), interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), @@ -618,17 +621,85 @@ impl Session { // the reader waits for the ack before calling readline again. // That keeps rustyline from painting "> " on top of a child // process (vim) that cmd_edit is running, and keeps it from - // racing the main loop in general. + // racing the main loop in general. In TUI mode the prompt is + // a persistent widget (no readline repaint race), so the ack + // is plumbed through but currently unused — kept so the + // rustyline and TUI paths share the same signature. let (ack_tx, ack_rx) = mpsc::unbounded_channel::<()>(); *self.input_ack_tx.lock().await = Some(ack_tx); - tokio::task::spawn_blocking(move || read_stdin(tx, ack_rx)); + // --stdio always wins, even if --tui was also passed — so a + // redirected-to-file run stays line-buffered and doesn't + // enter the alt screen / raw mode. + let use_tui = self.cfg.tui && !self.cfg.stdio; + if use_tui { + let scrollback = crate::tui::Scrollback::new(); + crate::tui::install_tui_printer(scrollback.clone()); + // Shared task-snapshot cell. A tokio task refreshes it + // every 200 ms; the TUI status closure reads it + // synchronously with no block_on / no Handle dance (the + // TUI runs under spawn_blocking, off the tokio scheduler, + // so calling block_on from there would deadlock or + // panic). + let snap_cell: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let mgr_for_refresh = self.mgr.clone(); + let snap_cell_for_refresh = snap_cell.clone(); + let shutdown_for_refresh = self.mgr.root_shutdown().clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_millis(200)); + loop { + tokio::select! { + _ = shutdown_for_refresh.cancelled() => break, + _ = ticker.tick() => { + let snap = mgr_for_refresh.snapshot().await; + *snap_cell_for_refresh.lock().unwrap() = snap; + } + } + } + }); + let snap_cell_for_status = snap_cell.clone(); + let status_fn: crate::tui::StatusFn = Box::new(move |cols| { + // Render inside the lock — TaskSnapshot isn't Clone, + // and render_status_line only reads the slice so + // there's no reentrancy risk. + let guard = snap_cell_for_status.lock().unwrap(); + render_status_line(&guard, cols) + }); + // History file — matches the rustyline path at + // session.rs:3660. Sharing the same file means Up/Down + // recall works across interactive / TUI / --stdio + // invocations without per-mode silos. + let history_path = dirs::home_dir().map(|h| h.join(".kres").join("history")); + tokio::task::spawn_blocking(move || { + if let Err(e) = crate::tui::run_tui(tx, ack_rx, scrollback, status_fn, history_path) + { + eprintln!("tui: {e}"); + } + }); + } else { + // Non-TUI paths (rustyline and --stdio fallback) install + // a stdout bootstrap printer BEFORE read_stdin runs so + // every migrated `kres_core::async_eprintln!` call site + // reaches a real sink from the first line. The rustyline + // branch inside read_stdin later `replace_printer`s this + // with its ExternalPrinter once the editor finishes + // booting, which is what makes the prompt-aware printing + // kick in. --stdio keeps the stdout printer for the + // whole session so redirected output (`kres --stdio … + // > out.txt`) captures everything. + crate::tui::install_stdout_printer(); + tokio::task::spawn_blocking(move || read_stdin(tx, ack_rx)); + } // Reserve the bottom two rows for a status bar + prompt. // Scrolling output stays above; status shows what each task // is currently doing. install() returns geometry only when // stderr is a tty and terminal is tall enough (≥3 rows). // --stdio forces the plain path even when stdout is a tty. - let status_geom = if self.cfg.stdio { + // --tui owns the terminal via crossterm, so the DECSTBM + // scroll region is suppressed too; the TUI paints its own + // status row. + let status_geom = if self.cfg.stdio || use_tui { None } else { crate::status::install() @@ -1009,10 +1080,8 @@ impl Session { // downstream: filter_net_new sees the // full `all_known` and RENAMES colliding // ids rather than dropping them. - let prose_relevant = kres_core::relevant_subset( - &effective_analysis, - &all_known, - ); + let prose_relevant = + kres_core::relevant_subset(&effective_analysis, &all_known); // Both slices go to the promoter's // prompt path — redact Finding.details // so the per-task narrative captured for @@ -1054,8 +1123,7 @@ impl Session { "[promote] {} prose-only bug(s) promoted to findings", extras.len() ); - promoted_ids - .extend(extras.iter().map(|f| f.id.clone())); + promoted_ids.extend(extras.iter().map(|f| f.id.clone())); working_delta.extend(extras); } Ok(_) => {} @@ -1097,12 +1165,8 @@ impl Session { // `redact_findings_for_agent`. if !effective_analysis.is_empty() { if let Some(ref s) = store_for_reaper { - if let Err(e) = - s.append_task_prose(&stamp, &effective_analysis).await - { - kres_core::async_eprintln!( - "task_prose append: {e}" - ); + if let Err(e) = s.append_task_prose(&stamp, &effective_analysis).await { + kres_core::async_eprintln!("task_prose append: {e}"); } } } @@ -1127,7 +1191,8 @@ impl Session { let prose_c = prose_for_details.clone(); let report = mgr_for_reaper .with_findings_extract_lock(|| async move { - s_c.apply_delta(&delta, Some(&stamp_c), Some(&prose_c)).await + s_c.apply_delta(&delta, Some(&stamp_c), Some(&prose_c)) + .await }) .await; match report { @@ -1660,16 +1725,16 @@ impl Session { let _ = kres_core::consent::install(Arc::new(kres_core::ConsentStore::new())); print_banner(); if !self.lenses.is_empty() { - println!( + kres_core::async_eprintln!( "installed {} session-wide slow-agent lens(es):", self.lenses.len() ); for l in &self.lenses { - println!(" [{}] {}", l.kind, l.name); + kres_core::async_eprintln!(" [{}] {}", l.kind, l.name); } } if let Some(ref p) = self.initial_prompt { - println!("submitting initial prompt from --prompt"); + kres_core::async_eprintln!("submitting initial prompt from --prompt"); self.submit_prompt(p.clone()).await; } let root_shutdown = self.mgr.root_shutdown().clone(); @@ -1758,7 +1823,7 @@ impl Session { Command::Next => self.cmd_next().await, Command::Continue => self.cmd_continue().await, Command::Quit => { - println!("bye"); + kres_core::async_eprintln!("bye"); // Fast-path teardown. Cancel root so every reaper / // orchestrator / task future awaiting cancellation // wakes up now (instead of waiting for stop_all to @@ -1773,9 +1838,11 @@ impl Session { .stop_all(std::time::Duration::from_millis(500)) .await; if out.requested > 0 { - println!( + kres_core::async_eprintln!( "teardown: {}/{} stopped, {} grace-expired", - out.stopped, out.requested, out.grace_expired + out.stopped, + out.requested, + out.grace_expired ); } ctrlc_handle.abort(); @@ -1790,7 +1857,7 @@ impl Session { return Ok(()); } Command::Unknown(name) => { - println!("unknown command: /{name} (try /help)"); + kres_core::async_eprintln!("unknown command: /{name} (try /help)"); } Command::Prompt(text) => { // submit_prompt awaits define_goal inline before @@ -1848,9 +1915,11 @@ impl Session { let out = self.mgr.stop_all(self.cfg.stop_grace).await; if out.requested > 0 { - println!( + kres_core::async_eprintln!( "teardown: {}/{} stopped, {} grace-expired", - out.stopped, out.requested, out.grace_expired + out.stopped, + out.requested, + out.grace_expired ); } ctrlc_handle.abort(); @@ -1913,8 +1982,10 @@ impl Session { todo_tag: Option, ) { let Some(orc) = self.orchestrator.clone() else { - println!("(no orchestrator configured — prompt dropped)"); - println!("hint: rerun `kres repl` with agent configs to enable prompt handling"); + kres_core::async_eprintln!("(no orchestrator configured — prompt dropped)"); + kres_core::async_eprintln!( + "hint: rerun `kres repl` with agent configs to enable prompt handling" + ); return; }; // Operator engaged — clear the /stop latch so auto-continue @@ -2210,12 +2281,13 @@ impl Session { async fn print_tasks(&self) { let snap = self.mgr.snapshot().await; - if snap.is_empty() { - println!("(no tasks)"); - return; - } - println!("{} task(s):", snap.len()); - for t in snap { + // Always emit a header so /tasks is visibly acknowledged + // even on an empty list. Previously the empty case printed + // a bare "(no tasks)" which was easy to miss in a busy + // scrollback; the /tasks: prefix makes it obvious this is + // the command's response. + kres_core::async_eprintln!("/tasks: {} active", snap.len()); + for t in &snap { let badge = match t.state { TaskState::Pending => "pending", TaskState::Running => "running", @@ -2223,14 +2295,14 @@ impl Session { TaskState::Done => "done", TaskState::Errored => "errored", }; - println!(" [{:>10}] #{} {}", badge, t.id, t.name); + kres_core::async_eprintln!(" [{:>10}] #{} {}", badge, t.id, t.name); } } async fn print_findings(&self) { let findings = self.mgr.findings_snapshot().await; if findings.is_empty() { - println!("(no findings yet)"); + kres_core::async_eprintln!("(no findings yet)"); return; } let (hi, med, lo) = findings.iter().fold((0, 0, 0), |(h, m, l), f| { @@ -2241,7 +2313,7 @@ impl Session { Low => (h, m, l + 1), } }); - println!( + kres_core::async_eprintln!( "{} findings: {} high, {} medium, {} low", findings.len(), hi, @@ -2249,7 +2321,7 @@ impl Session { lo ); for f in findings.iter().take(20) { - println!( + kres_core::async_eprintln!( " [{:>8?}] {} — {}", f.severity, f.id, @@ -2257,7 +2329,7 @@ impl Session { ); } if findings.len() > 20 { - println!(" … {} more", findings.len() - 20); + kres_core::async_eprintln!(" … {} more", findings.len() - 20); } } @@ -2288,7 +2360,7 @@ impl Session { let mut deferred = self.deferred.lock().await; deferred.extend(drained); drop(deferred); - println!( + kres_core::async_eprintln!( "/stop: requested={} stopped={} grace_expired={} (auto-continue paused; {} pending item(s) moved to /followup; /continue or a new prompt resumes)", out.requested, out.stopped, out.grace_expired, carry ); @@ -2304,7 +2376,7 @@ impl Session { // gets their work back before new items start. let stashed = self.interrupted_prompt.lock().await.take(); if let Some(prompt) = stashed { - println!( + kres_core::async_eprintln!( "/continue: resuming interrupted prompt: {}", truncate(&prompt, 80) ); @@ -2331,7 +2403,9 @@ impl Session { items.push(d); } self.mgr.replace_todo(items).await; - println!("/continue: pulled {carry} deferred item(s) into todo list"); + kres_core::async_eprintln!( + "/continue: pulled {carry} deferred item(s) into todo list" + ); } } // §15: cap the batch at 10 items per `/continue` to match @@ -2385,7 +2459,7 @@ impl Session { ", {remaining} left — /continue again to process next batch" )); } - println!("{msg}"); + kres_core::async_eprintln!("{msg}"); } async fn cmd_next(&self) { @@ -2406,9 +2480,9 @@ impl Session { .filter(|i| i.status == TodoStatus::Pending) .count(); if pending == 0 { - println!("/next: nothing pending"); + kres_core::async_eprintln!("/next: nothing pending"); } else { - println!( + kres_core::async_eprintln!( "/next: {} pending item(s) but all are blocked on unfinished deps", pending ); @@ -2425,7 +2499,7 @@ impl Session { self.mgr .mark_todo_status(&item.name, TodoStatus::InProgress) .await; - println!("/next: dispatching {}", truncate(&item.name, 80)); + kres_core::async_eprintln!("/next: dispatching {}", truncate(&item.name, 80)); let tag = if !item.id.is_empty() { item.id.clone() } else { @@ -2442,7 +2516,7 @@ impl Session { chrono::Utc::now().timestamp_millis() )); if let Err(e) = std::fs::write(&tmp, "") { - println!("/edit: create tempfile: {e}"); + kres_core::async_eprintln!("/edit: create tempfile: {e}"); return; } // Tear down kres's DECSTBM scroll region (status.rs:50) and @@ -2486,11 +2560,11 @@ impl Session { let content = match status { Ok(Ok(_)) => std::fs::read_to_string(&tmp).ok(), Ok(Err(e)) => { - println!("/edit: editor spawn failed: {e}"); + kres_core::async_eprintln!("/edit: editor spawn failed: {e}"); None } Err(e) => { - println!("/edit: join error: {e}"); + kres_core::async_eprintln!("/edit: join error: {e}"); None } }; @@ -2500,7 +2574,7 @@ impl Session { let Some(text) = content else { return }; let trimmed = text.trim(); if trimmed.is_empty() { - println!("/edit: empty, nothing submitted"); + kres_core::async_eprintln!("/edit: empty, nothing submitted"); return; } self.submit_prompt(trimmed.to_string()).await; @@ -2515,11 +2589,13 @@ impl Session { (Some(p), false) => format!("{}\n\n{}", p, text), (Some(p), true) => p, (None, false) => { - println!("/reply: no prior analysis — submitting plain text"); + kres_core::async_eprintln!("/reply: no prior analysis — submitting plain text"); text } (None, true) => { - println!("/reply: no prior analysis and no new text — nothing to do"); + kres_core::async_eprintln!( + "/reply: no prior analysis and no new text — nothing to do" + ); return; } }; @@ -2528,32 +2604,40 @@ impl Session { async fn cmd_load(&self, path: String) { if path.is_empty() { - println!("usage: /load "); + kres_core::async_eprintln!("usage: /load "); return; } match std::fs::read_to_string(&path) { Ok(text) => { let trimmed = text.trim(); if trimmed.is_empty() { - println!("/load: {} is empty", path); + kres_core::async_eprintln!("/load: {} is empty", path); return; } - println!("/load: submitting {} chars from {}", trimmed.len(), path); + kres_core::async_eprintln!( + "/load: submitting {} chars from {}", + trimmed.len(), + path + ); self.submit_prompt(trimmed.to_string()).await; } - Err(e) => println!("/load: {}: {e}", path), + Err(e) => kres_core::async_eprintln!("/load: {}: {e}", path), } } async fn cmd_report(&self, path: String) { if path.is_empty() { - println!("usage: /report .md"); + kres_core::async_eprintln!("usage: /report .md"); return; } let findings = self.mgr.findings_snapshot().await; match crate::report::write_findings_to_file(&findings, std::path::Path::new(&path)) { - Ok(()) => println!("/report: wrote {} finding(s) to {}", findings.len(), path), - Err(e) => println!("/report: {}: {e}", path), + Ok(()) => kres_core::async_eprintln!( + "/report: wrote {} finding(s) to {}", + findings.len(), + path + ), + Err(e) => kres_core::async_eprintln!("/report: {}: {e}", path), } } @@ -2575,7 +2659,7 @@ impl Session { None => { // Derive the backup + live paths from cfg.persist_path. let Some(live) = self.cfg.persist_path.as_ref() else { - println!( + kres_core::async_eprintln!( "/resume: no persist path configured (kres was started \ without a results dir)" ); @@ -2586,7 +2670,7 @@ impl Session { let prev_name = match live.file_name() { Some(n) => format!("{}.prev", n.to_string_lossy()), None => { - println!("/resume: persist path has no filename"); + kres_core::async_eprintln!("/resume: persist path has no filename"); return; } }; @@ -2596,7 +2680,7 @@ impl Session { } else if live.exists() { live.clone() } else { - println!( + kres_core::async_eprintln!( "/resume: neither {} nor {} exists — nothing to load", prev.display(), live.display() @@ -2607,7 +2691,7 @@ impl Session { }; match self.resume_state_from(Some(&chosen)).await { Ok(Some(state)) => { - println!( + kres_core::async_eprintln!( "/resume: loaded {} ({} todo, {} deferred, turns done={})", chosen.display(), state.todo.len(), @@ -2615,14 +2699,14 @@ impl Session { state.completed_run_count, ); if let Some(ref p) = state.last_prompt { - println!("/resume: last prompt: {}", truncate(p, 80)); + kres_core::async_eprintln!("/resume: last prompt: {}", truncate(p, 80)); } } Ok(None) => { - println!("/resume: {} is missing or empty", chosen.display()); + kres_core::async_eprintln!("/resume: {} is missing or empty", chosen.display()); } Err(e) => { - println!("/resume: {e}"); + kres_core::async_eprintln!("/resume: {e}"); } } } @@ -2638,7 +2722,7 @@ impl Session { // statuses right now, not whatever the planner last wrote. self.mgr.sync_plan_from_todo().await; let Some(plan) = self.mgr.plan_snapshot().await else { - println!( + kres_core::async_eprintln!( "(no plan — either no goal agent configured or define_plan failed on the last prompt)" ); return; @@ -2650,12 +2734,12 @@ impl Session { // the step-side list is often empty while todos actually // point at the step via their own step_id field. let todo = self.mgr.todo_snapshot().await; - println!( + kres_core::async_eprintln!( "plan — mode={}, {} step(s)", plan.mode.as_str(), plan.steps.len() ); - println!("goal: {}", truncate(&plan.goal, 120)); + kres_core::async_eprintln!("goal: {}", truncate(&plan.goal, 120)); for s in &plan.steps { let status = match s.status { kres_core::PlanStepStatus::Pending => "pending", @@ -2663,9 +2747,9 @@ impl Session { kres_core::PlanStepStatus::Done => "done", kres_core::PlanStepStatus::Skipped => "skipped", }; - println!(" [{}] {:<11} {}", s.id, status, truncate(&s.title, 80)); + kres_core::async_eprintln!(" [{}] {:<11} {}", s.id, status, truncate(&s.title, 80)); if !s.description.is_empty() { - println!(" — {}", truncate(&s.description, 120)); + kres_core::async_eprintln!(" — {}", truncate(&s.description, 120)); } // Union of step.todo_ids (down-link) and todos whose // step_id matches s.id (up-link). Dedup by the todo's @@ -2701,7 +2785,7 @@ impl Session { } }) .collect(); - println!(" linked: {}", labels.join(", ")); + kres_core::async_eprintln!(" linked: {}", labels.join(", ")); } } } @@ -2710,13 +2794,15 @@ impl Session { /// cap. Matches command. async fn cmd_followup(&self) { let def = self.deferred.lock().await; + // Always emit the banner so /followup is visibly acknowledged + // even on an empty list — operators otherwise can't tell + // whether the command ran or the main loop was busy. + kres_core::async_eprintln!("/followup: {} deferred item(s)", def.len()); if def.is_empty() { - println!("(no deferred items)"); return; } - println!("deferred ({}):", def.len()); for (i, item) in def.iter().enumerate() { - println!( + kres_core::async_eprintln!( " {:3}. [{}] {} ({})", i + 1, item.kind, @@ -2730,7 +2816,7 @@ impl Session { } ); if !item.reason.is_empty() { - println!(" — {}", truncate(&item.reason, 120)); + kres_core::async_eprintln!(" — {}", truncate(&item.reason, 120)); } } } @@ -2873,7 +2959,7 @@ impl Session { let dir_buf = dir.as_ref().map(std::path::PathBuf::from); if let Some(ref d) = dir_buf { if let Err(e) = std::fs::create_dir_all(d) { - println!("/extract: create {}: {e}", d.display()); + kres_core::async_eprintln!("/extract: create {}: {e}", d.display()); return; } } @@ -2887,12 +2973,12 @@ impl Session { if let Some(p) = resolve("report.md", report.as_ref()) { let findings = self.mgr.findings_snapshot().await; match crate::report::write_findings_to_file(&findings, &p) { - Ok(()) => println!( + Ok(()) => kres_core::async_eprintln!( "/extract: wrote {} finding(s) to {}", findings.len(), p.display() ), - Err(e) => println!("/extract: report {}: {e}", p.display()), + Err(e) => kres_core::async_eprintln!("/extract: report {}: {e}", p.display()), } } // Todo: write current todo list (pending+done) as markdown. @@ -2912,8 +2998,12 @@ impl Session { md.push('\n'); } match std::fs::write(&p, md) { - Ok(()) => println!("/extract: wrote {} todo(s) to {}", items.len(), p.display()), - Err(e) => println!("/extract: todo {}: {e}", p.display()), + Ok(()) => kres_core::async_eprintln!( + "/extract: wrote {} todo(s) to {}", + items.len(), + p.display() + ), + Err(e) => kres_core::async_eprintln!("/extract: todo {}: {e}", p.display()), } } // Findings: dump the structured JSON. @@ -2921,14 +3011,14 @@ impl Session { let list = self.mgr.findings_snapshot().await; match serde_json::to_string_pretty(&list) { Ok(s) => match std::fs::write(&p, s) { - Ok(()) => println!( + Ok(()) => kres_core::async_eprintln!( "/extract: wrote {} finding(s) to {}", list.len(), p.display() ), - Err(e) => println!("/extract: findings {}: {e}", p.display()), + Err(e) => kres_core::async_eprintln!("/extract: findings {}: {e}", p.display()), }, - Err(e) => println!("/extract: findings serialise: {e}"), + Err(e) => kres_core::async_eprintln!("/extract: findings serialise: {e}"), } } } @@ -2936,7 +3026,7 @@ impl Session { /// `/done N` — remove the N'th (1-based) pending todo item. async fn cmd_done(&self, index: usize) { if index == 0 { - println!("/done: 1-based index expected"); + kres_core::async_eprintln!("/done: 1-based index expected"); return; } let items = self.mgr.todo_snapshot().await; @@ -2950,7 +3040,7 @@ impl Session { }) .collect(); if index > pending.len() { - println!( + kres_core::async_eprintln!( "/done: index {} out of range ({} pending)", index, pending.len() @@ -2963,7 +3053,7 @@ impl Session { .filter(|t| t.name != target_name) .collect(); self.mgr.replace_todo(new_list).await; - println!("/done: removed {}", truncate(&target_name, 80)); + kres_core::async_eprintln!("/done: removed {}", truncate(&target_name, 80)); } /// §46: decide whether the idle loop should auto-launch a @@ -2996,16 +3086,12 @@ impl Session { /// `/todo --clear` — drop every todo item. async fn cmd_todo_clear(&self) { self.mgr.replace_todo(Vec::new()).await; - println!("/todo: cleared"); + kres_core::async_eprintln!("/todo: cleared"); } async fn print_todo(&self) { use kres_core::TodoStatus; let items = self.mgr.todo_snapshot().await; - if items.is_empty() { - println!("(todo list empty)"); - return; - } let pending = items .iter() .filter(|i| i.status == TodoStatus::Pending) @@ -3018,8 +3104,12 @@ impl Session { .iter() .filter(|i| i.status == TodoStatus::Done) .count(); - println!( - "{} todo item(s): {} pending, {} running, {} done", + // Always emit the banner so /todo is visibly acknowledged + // even on an empty list — the "/todo:" prefix also makes + // the response identifiable in a busy scrollback full of + // agent output. + kres_core::async_eprintln!( + "/todo: {} item(s) ({} pending, {} running, {} done)", items.len(), pending, running, @@ -3033,25 +3123,25 @@ impl Session { TodoStatus::Blocked => "blocked", TodoStatus::Skipped => "skipped", }; - println!(" [{:>7}] [{}] {}", badge, i.kind, i.name); + kres_core::async_eprintln!(" [{:>7}] [{}] {}", badge, i.kind, i.name); } if items.len() > 30 { - println!(" … {} more", items.len() - 30); + kres_core::async_eprintln!(" … {} more", items.len() - 30); } } fn print_cost(&self) { let snap = self.usage.snapshot(); if snap.is_empty() { - println!("(no API usage recorded yet)"); + kres_core::async_eprintln!("(no API usage recorded yet)"); return; } let total = self.usage.totals(); // Show per-row input/output and cache-create/cache-read, // plus a total line. - println!("usage ({} call(s) total):", total.calls); + kres_core::async_eprintln!("usage ({} call(s) total):", total.calls); for (k, e) in &snap { - println!( + kres_core::async_eprintln!( " {:>4}/{:<24} {:>4}× in={:>9} out={:>9} cache_create={:>9} cache_read={:>9}", k.role, k.model, @@ -3062,7 +3152,7 @@ impl Session { fmt_k(e.cache_read_input_tokens), ); } - println!( + kres_core::async_eprintln!( " total {:>4}× in={:>9} out={:>9} cache_create={:>9} cache_read={:>9}", total.calls, fmt_k(total.input_tokens), @@ -3090,7 +3180,7 @@ impl Session { // prompt on a different topic could quietly read paths the // operator forgot they'd allowed. let dropped_grants = kres_core::consent::get().map(|s| s.clear()).unwrap_or(0); - println!( + kres_core::async_eprintln!( "/clear: stopped {} task(s), reset findings + todo + accumulated context, dropped {} consent grant(s)", out.stopped + out.grace_expired, dropped_grants @@ -3105,14 +3195,14 @@ impl Session { async fn cmd_compact(&self) { let entries = self.accumulated.lock().await.clone(); if entries.len() <= 1 { - println!( + kres_core::async_eprintln!( "/compact: nothing to compact (ledger has {} entry)", entries.len() ); return; } let Some(orc) = self.orchestrator.as_ref() else { - println!("/compact: no orchestrator configured"); + kres_core::async_eprintln!("/compact: no orchestrator configured"); return; }; // Build the inference request: feed every accumulated entry @@ -3134,7 +3224,7 @@ impl Session { let body = match serde_json::to_string_pretty(&request) { Ok(s) => s, Err(e) => { - println!("/compact: serialise failed: {e}"); + kres_core::async_eprintln!("/compact: serialise failed: {e}"); return; } }; @@ -3159,7 +3249,9 @@ impl Session { let resp = match orc.fast_client.messages_streaming(&cfg, &messages).await { Ok(r) => r, Err(e) => { - println!("/compact: fast-agent call failed: {e}; ledger unchanged"); + kres_core::async_eprintln!( + "/compact: fast-agent call failed: {e}; ledger unchanged" + ); return; } }; @@ -3208,7 +3300,7 @@ impl Session { let summary = match summary { Some(s) => s, None => { - println!( + kres_core::async_eprintln!( "/compact: could not parse a summary from the fast agent; ledger unchanged" ); return; @@ -3221,7 +3313,7 @@ impl Session { }; let mut guard = self.accumulated.lock().await; *guard = vec![replaced]; - println!( + kres_core::async_eprintln!( "/compact: replaced {before} entry(s) with a {}-char summary", summary.len() ); @@ -3618,7 +3710,7 @@ async fn persist_code_output(workspace: &Path, task_name: &str, files: &[kres_co fn report_reaped(r: &kres_core::ReapedTask) { match r.state { kres_core::TaskState::Done => { - println!( + kres_core::async_eprintln!( "== done #{} {} ({} findings, {} char analysis)", r.id, truncate(&r.name, 60), @@ -3631,13 +3723,13 @@ fn report_reaped(r: &kres_core::ReapedTask) { // past and then ... nothing. Full body on stdout matches // the 's behaviour. if !r.analysis.is_empty() { - println!(); - println!("{}", r.analysis); - println!(); + kres_core::async_eprintln!(""); + kres_core::async_eprintln!("{}", r.analysis); + kres_core::async_eprintln!(""); } } kres_core::TaskState::Errored => { - println!( + kres_core::async_eprintln!( "== error #{} {} — {}", r.id, truncate(&r.name, 60), @@ -3670,7 +3762,12 @@ fn read_stdin(tx: mpsc::UnboundedSender, mut ack_rx: mpsc::UnboundedRece // async_println without a kres-repl dep. if let Ok(mut printer) = editor.create_external_printer() { let (ptx, mut prx) = tokio::sync::mpsc::unbounded_channel::(); - let _ = kres_core::io::install_printer(Box::new(move |s| { + // `replace_printer` rather than `install_printer`: the + // caller in Session::run already installed a stdout- + // bootstrap printer so `print_banner` and friends had a + // sink. Now that the ExternalPrinter is ready, take over so + // subsequent lines arrive through the prompt-aware channel. + kres_core::io::replace_printer(Box::new(move |s| { let _ = ptx.send(s); })); std::thread::spawn(move || { @@ -3789,42 +3886,64 @@ fn print_banner() { // (see main.rs). Here we emit the header + the quick-command // hint — the per-run context (skills, artifacts dir, etc.) is // already on stderr by the time the REPL loop starts. - println!("kres — kernel code research agent"); - println!("type /help for commands, /quit to exit"); - println!("ctrl-g: editor | /clear: reset | /quit: exit"); + kres_core::async_eprintln!("kres — kernel code research agent"); + kres_core::async_eprintln!("type /help for commands, /quit to exit"); + kres_core::async_eprintln!("ctrl-g: editor | /clear: reset | /quit: exit"); } fn print_help() { - println!("commands:"); - println!(" /help, /? show this help"); - println!(" /tasks, /task list running tasks"); - println!(" /findings summarise findings"); - println!(" /stop cancel running tasks"); - println!(" /clear stop tasks, reset findings + todo + accumulated context"); - println!(" /compact summarise accumulated context into one short entry"); - println!(" /cost show API token usage"); - println!(" /todo show the todo list"); - println!(" /plan show the current plan (produced by define_plan)"); - println!(" /resume [PATH] load a persisted session.json (backup, live, or PATH)"); - println!(" /report write findings report (markdown)"); - println!(" /load submit a file's contents as the next prompt"); - println!(" /edit open $EDITOR on a scratch file, submit on save"); - println!(" /followup list items deferred by goal/--turns"); - println!( + kres_core::async_eprintln!("commands:"); + kres_core::async_eprintln!(" /help, /? show this help"); + kres_core::async_eprintln!(" /tasks, /task list running tasks"); + kres_core::async_eprintln!(" /findings summarise findings"); + kres_core::async_eprintln!(" /stop cancel running tasks"); + kres_core::async_eprintln!( + " /clear stop tasks, reset findings + todo + accumulated context" + ); + kres_core::async_eprintln!( + " /compact summarise accumulated context into one short entry" + ); + kres_core::async_eprintln!(" /cost show API token usage"); + kres_core::async_eprintln!(" /todo show the todo list"); + kres_core::async_eprintln!( + " /plan show the current plan (produced by define_plan)" + ); + kres_core::async_eprintln!( + " /resume [PATH] load a persisted session.json (backup, live, or PATH)" + ); + kres_core::async_eprintln!(" /report write findings report (markdown)"); + kres_core::async_eprintln!( + " /load submit a file's contents as the next prompt" + ); + kres_core::async_eprintln!( + " /edit open $EDITOR on a scratch file, submit on save" + ); + kres_core::async_eprintln!(" /followup list items deferred by goal/--turns"); + kres_core::async_eprintln!( " /review compose the embedded `review` template with and submit" ); - println!(" /summary [FILE] render report.md+findings.json into a plain-text summary (default summary.txt)"); - println!(" /summary-markdown [FILE] render the markdown variant (default summary.md)"); - println!(" /extract ... copy artifacts (--dir, --report, --todo, --findings)"); - println!(" /done N remove the N'th pending todo"); - println!(" /todo --clear drop every todo item"); - println!(" /reply prepend last analysis to new text, submit"); - println!(" /next dispatch the next pending todo item as a prompt"); - println!(" /continue dispatch every unblocked pending todo"); - println!(" /quit, /exit leave the REPL"); - println!(" submit as a prompt"); - println!(); - println!("override slash-command templates by dropping a file at ~/.kres/commands/.md"); + kres_core::async_eprintln!(" /summary [FILE] render report.md+findings.json into a plain-text summary (default summary.txt)"); + kres_core::async_eprintln!( + " /summary-markdown [FILE] render the markdown variant (default summary.md)" + ); + kres_core::async_eprintln!( + " /extract ... copy artifacts (--dir, --report, --todo, --findings)" + ); + kres_core::async_eprintln!(" /done N remove the N'th pending todo"); + kres_core::async_eprintln!(" /todo --clear drop every todo item"); + kres_core::async_eprintln!( + " /reply prepend last analysis to new text, submit" + ); + kres_core::async_eprintln!( + " /next dispatch the next pending todo item as a prompt" + ); + kres_core::async_eprintln!(" /continue dispatch every unblocked pending todo"); + kres_core::async_eprintln!(" /quit, /exit leave the REPL"); + kres_core::async_eprintln!(" submit as a prompt"); + kres_core::async_eprintln!(""); + kres_core::async_eprintln!( + "override slash-command templates by dropping a file at ~/.kres/commands/.md" + ); } fn truncate(s: &str, n: usize) -> String { diff --git a/kres-repl/src/summary.rs b/kres-repl/src/summary.rs index 42c5c96..d6991bc 100644 --- a/kres-repl/src/summary.rs +++ b/kres-repl/src/summary.rs @@ -213,7 +213,7 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { .collect(); active.sort_by(|a, b| severity_rank(b.severity).cmp(&severity_rank(a.severity))); - eprintln!( + kres_core::async_eprintln!( "summary: {} active finding(s) (filtered {} invalidated), {} task_prose entry(s)", active.len(), file.findings.len() - active.len(), @@ -231,7 +231,7 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { // so the condense calls and logs stay stable across runs on the // same input. let (task_order, mut tasks) = bucket_task_material(&active, &file); - eprintln!( + kres_core::async_eprintln!( "summary: {} distinct task id(s) contributing material", task_order.len() ); @@ -267,7 +267,7 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { // 5. Render pass. Resolve the template once; reuse it for the // single-shot attempt and any partial renders below. let (template_src, template_text) = resolve_template(&inputs)?; - eprintln!("summary: template = {}", template_src); + kres_core::async_eprintln!("summary: template = {}", template_src); let mut render_cfg = CallConfig::defaults_for(inputs.model.clone()) .with_max_tokens(inputs.max_tokens) @@ -289,15 +289,11 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { // The observations block is typically small relative to // findings bodies — we always send it whole alongside every // render call (single-shot and each partial). - let full_prompt = build_render_prompt( - original_prompt, - &render_findings, - &task_observations, - None, - )?; + let full_prompt = + build_render_prompt(original_prompt, &render_findings, &task_observations, None)?; let full_messages = vec![user_message(&full_prompt)]; let size = size_call(&inputs.client, &render_cfg, &full_messages, budget).await; - eprintln!( + kres_core::async_eprintln!( "summary: render sizing findings={} observations_chars={} tokens={:?} budget={}", render_findings.len(), task_observations.len(), @@ -307,13 +303,23 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { let needs_staging = size.map(|t| t > budget as u64).unwrap_or(false); let text = if !needs_staging { - eprintln!( + kres_core::async_eprintln!( "summary: single-shot render to {} ({} finding(s), original_prompt={})", inputs.model.id, render_findings.len(), - if original_prompt.is_empty() { "no" } else { "yes" }, + if original_prompt.is_empty() { + "no" + } else { + "yes" + }, ); - call_and_extract(&inputs.client, &render_cfg, &full_messages, "summary render").await? + call_and_extract( + &inputs.client, + &render_cfg, + &full_messages, + "summary render", + ) + .await? } else { stage_render( &inputs, @@ -338,7 +344,7 @@ pub async fn run_summary(inputs: SummaryInputs) -> Result<()> { } std::fs::write(&inputs.output_path, &text) .with_context(|| format!("writing summary to {}", inputs.output_path.display()))?; - eprintln!( + kres_core::async_eprintln!( "summary: wrote {} chars to {}", text.len(), inputs.output_path.display(), @@ -366,10 +372,11 @@ fn bucket_task_material( if seen.insert(d.task.clone()) { order.push(d.task.clone()); } - out.entry(d.task.clone()) - .or_default() - .per_finding - .push((f.id.clone(), f.title.clone(), d.analysis.clone())); + out.entry(d.task.clone()).or_default().per_finding.push(( + f.id.clone(), + f.title.clone(), + d.analysis.clone(), + )); } } @@ -421,7 +428,7 @@ async fn condense_tasks_batched( for (idx, task_id) in task_order.iter().enumerate() { let material = tasks.remove(task_id).unwrap_or_default(); - eprintln!( + kres_core::async_eprintln!( "summary: packing task {}/{} id={} findings={} prose_chars={}", idx + 1, task_order.len(), @@ -465,7 +472,7 @@ async fn condense_tasks_batched( continue; } - eprintln!( + kres_core::async_eprintln!( "summary: task {} alone exceeds budget; falling back to single-task split", truncate(&offender_id, 40), ); @@ -488,7 +495,7 @@ async fn condense_tasks_batched( blocks.push(block); } - eprintln!( + kres_core::async_eprintln!( "summary: condense produced {} block(s) across {} batch call(s)", blocks.len(), batch_n, @@ -529,7 +536,7 @@ async fn flush_batch( let prompt = build_batch_condense_prompt(batch)?; let messages = vec![user_message(&prompt)]; let label = format!("summary condense batch {batch_n}"); - eprintln!( + kres_core::async_eprintln!( "summary: condense batch {} — {} task(s)", batch_n, batch.len() @@ -557,7 +564,7 @@ async fn condense_single_task( if fits { return call_and_extract(client, cfg, &messages, label).await; } - eprintln!( + kres_core::async_eprintln!( "summary: single-task condense oversize for {} (budget={}); splitting", truncate(task_id, 40), budget, @@ -577,8 +584,14 @@ async fn condense_single_task( }; let l1 = format!("{label} 1/2"); let l2 = format!("{label} 2/2"); - let a = Box::pin(condense_single_task(client, cfg, task_id, &first, &l1, budget)).await?; - let b = Box::pin(condense_single_task(client, cfg, task_id, &second, &l2, budget)).await?; + let a = Box::pin(condense_single_task( + client, cfg, task_id, &first, &l1, budget, + )) + .await?; + let b = Box::pin(condense_single_task( + client, cfg, task_id, &second, &l2, budget, + )) + .await?; let mut joined = a; if !joined.ends_with('\n') { joined.push('\n'); @@ -594,13 +607,12 @@ async fn condense_single_task( per_finding: material.per_finding.clone(), prose: String::new(), }; - let stripped_pending: Vec<(String, TaskMaterial)> = - vec![(task_id.to_string(), stripped)]; + let stripped_pending: Vec<(String, TaskMaterial)> = vec![(task_id.to_string(), stripped)]; let stripped_prompt = build_batch_condense_prompt(&stripped_pending)?; let stripped_messages = vec![user_message(&stripped_prompt)]; let stripped_size = size_call(client, cfg, &stripped_messages, budget).await; if stripped_size.map(|t| t <= budget as u64).unwrap_or(true) { - eprintln!( + kres_core::async_eprintln!( "summary: condense dropping task_prose ({} chars) for {} to fit budget", material.prose.len(), truncate(task_id, 40), @@ -658,7 +670,7 @@ async fn stage_render( budget, ) .await?; - eprintln!( + kres_core::async_eprintln!( "summary: staging: {} batch(es) over {} finding(s); rendering partials then combining", batches.len(), findings.len(), @@ -667,11 +679,15 @@ async fn stage_render( let mut partials = Vec::with_capacity(batches.len()); for (idx, batch) in batches.iter().enumerate() { let note = partial_note(idx + 1, batches.len()); - let prompt_json = - build_render_prompt(original_prompt, batch, task_observations, Some(note.as_str()))?; + let prompt_json = build_render_prompt( + original_prompt, + batch, + task_observations, + Some(note.as_str()), + )?; let messages = vec![user_message(&prompt_json)]; let label = format!("summary render partial {}/{}", idx + 1, batches.len()); - eprintln!( + kres_core::async_eprintln!( "summary: partial {}/{} — {} finding(s), observations_chars={}", idx + 1, batches.len(), @@ -697,7 +713,7 @@ async fn stage_render( }))?; let combine_messages = vec![user_message(&combine_json)]; let combine_size = size_call(&inputs.client, &combine_cfg, &combine_messages, budget).await; - eprintln!( + kres_core::async_eprintln!( "summary: combine sizing partials={} tokens={:?} budget={}", partials.len(), combine_size, @@ -711,7 +727,7 @@ async fn stage_render( )); } } - eprintln!( + kres_core::async_eprintln!( "summary: combining {} partial(s) into final output", partials.len() ); @@ -944,7 +960,7 @@ async fn call_and_extract( resp.stop_reason )); } - eprintln!( + kres_core::async_eprintln!( "{stage}: {} chars (usage in={} out={})", text.len(), resp.usage.input_tokens, @@ -1116,7 +1132,10 @@ mod tests { }; let (order, map) = bucket_task_material(&findings, &file); assert!(order.contains(&"task-prose-only".to_string())); - assert_eq!(map.get("task-prose-only").unwrap().prose, "general narrative"); + assert_eq!( + map.get("task-prose-only").unwrap().prose, + "general narrative" + ); } #[test] diff --git a/kres-repl/src/tui.rs b/kres-repl/src/tui.rs new file mode 100644 index 0000000..32475e8 --- /dev/null +++ b/kres-repl/src/tui.rs @@ -0,0 +1,1932 @@ +//! Ratatui-based interactive loop — stage 1 of the TUI migration. +//! +//! This module is a drop-in replacement for [`session::read_stdin`] +//! when the session is started with `--tui`. It owns the terminal +//! (raw mode + alternate screen) for the lifetime of the loop, runs +//! a crossterm event poll, and feeds submitted lines into the same +//! `mpsc::UnboundedSender` the rustyline path uses — so the +//! rest of `Session::run` doesn't care which input driver produced +//! the line. +//! +//! What stage 1 ships: +//! - Scrollback pane (top) fed by [`kres_core::io::install_printer`]. +//! Every `async_println` / `async_eprintln` call site in the crates +//! becomes a line in the TUI buffer; no console paint races. +//! - Status row (one line above the input) driven by the same +//! [`render_status_line`](crate::session::render_status_line) the +//! DECSTBM path already uses. +//! - Single-line input bar with insert / backspace / delete / +//! Home/End / Left-Right / Enter-submit / Ctrl-C-cancel / Ctrl-D-EOF. +//! +//! What stage 1 deliberately does NOT ship (follow-up stages): +//! - History, Ctrl-R incremental search (rustyline still owns these +//! in the default path). +//! - Ctrl-G `/edit` handoff to $EDITOR. In TUI mode the TUI owns the +//! terminal; a follow-up will suspend ratatui before spawning vim +//! and resume after. Until then `/edit` still submits as a command +//! and the editor output will fight the frame. +//! - Multi-line prompt editing (Shift-Enter etc.). +//! - Mouse scrollback, search, panes, findings sidebar. +//! +//! Teardown: [`run_tui`] always restores the terminal (leave raw +//! mode, leave alt screen, show cursor) before returning, even on +//! panic, via [`TuiGuard`]. If kres is killed uncleanly the user may +//! need `reset` — same caveat as the existing DECSTBM path. +//! +//! `--stdio` takes precedence: when both `--stdio` and `--tui` are +//! set, `--stdio` wins (the plain line-buffered path stays in +//! charge) so output redirection keeps working unchanged. +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crossterm::{ + event::{ + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Terminal, +}; +use tokio::sync::mpsc; + +/// Bounded ring of lines rendered in the scrollback pane. A growing +/// `Vec` is fine because `async_println` volume is low +/// (agent-emitted status, not per-token streaming), but an upper +/// bound stops a pathological agent from leaking unbounded memory +/// over a long session. +pub const SCROLLBACK_CAP: usize = 10_000; + +/// Shared state between the crossterm event loop (which owns input) +/// and background writers (which push lines via the installed +/// printer). The mutex is held only for the time it takes to +/// push/pop a line, so contention with the event loop's per-frame +/// snapshot is negligible. +/// +/// Each line gets a monotonically increasing **logical id**. The +/// `Vec` only ever stores the most recent `SCROLLBACK_CAP` +/// lines, but the logical id survives eviction: the view anchor in +/// `run_tui` is a logical id, so a pinned scrollback position +/// doesn't drift when the ring evicts oldest entries. Without the +/// id mapping, the anchor would be an absolute `Vec` index that +/// points at different content after every drain — visually, the +/// pinned view would jump forward by the drain count. +#[derive(Clone, Default)] +pub struct Scrollback { + inner: Arc>, +} + +#[derive(Default)] +struct ScrollbackInner { + lines: Vec, + /// Logical id of `lines[0]`. Grows by the drain count on each + /// push that exceeds `SCROLLBACK_CAP`. `first_id == 0` while the + /// ring has never evicted. + first_id: usize, +} + +impl Scrollback { + pub fn new() -> Self { + Self::default() + } + /// Append a line, trimming the oldest entries if we're over cap. + /// Called from every async_println site via the installed + /// printer closure. A line may contain embedded newlines; we + /// split so the renderer sees one entry per visual line. + pub fn push(&self, s: &str) { + let mut g = self.inner.lock().unwrap(); + for chunk in s.split('\n') { + g.lines.push(chunk.to_string()); + } + let len = g.lines.len(); + if len > SCROLLBACK_CAP { + let drop = len - SCROLLBACK_CAP; + g.lines.drain(0..drop); + g.first_id += drop; + } + } + /// Snapshot the last `max_rows` lines for a draw tick. Cheap — + /// clones at most `max_rows` strings (on a 50-row terminal that's + /// ~50 allocations per 100ms tick). + pub fn tail(&self, max_rows: usize) -> Vec { + let g = self.inner.lock().unwrap(); + let start = g.lines.len().saturating_sub(max_rows); + g.lines[start..].to_vec() + } + /// Snapshot a window ending `offset` lines above the newest + /// entry. `offset = 0` is equivalent to [`tail`]; `offset = N` + /// walks back N lines and returns the `max_rows` window ending + /// at `len - offset`. Returns an empty Vec when offset walks past + /// the oldest entry. + pub fn window(&self, max_rows: usize, offset: usize) -> Vec { + let g = self.inner.lock().unwrap(); + let end = g.lines.len().saturating_sub(offset); + let start = end.saturating_sub(max_rows); + g.lines[start..end].to_vec() + } + /// Snapshot `max_rows` starting at logical line id + /// `anchor_id`. If `anchor_id` has already been evicted (i.e. + /// it's older than `first_id`), snap to the oldest retained + /// line instead of returning empty — matches `less`'s behaviour + /// of following the top when the tail of a rotating log moves + /// past your position. + pub fn window_from(&self, anchor_id: usize, max_rows: usize) -> Vec { + let g = self.inner.lock().unwrap(); + let vec_start = anchor_id.saturating_sub(g.first_id).min(g.lines.len()); + let vec_end = (vec_start + max_rows).min(g.lines.len()); + g.lines[vec_start..vec_end].to_vec() + } + /// Number of currently-retained lines. Doesn't count evicted + /// entries — use `total_logical_lines` when you need the + /// ever-pushed count (for clamping an anchor id, say). + pub fn len(&self) -> usize { + self.inner.lock().unwrap().lines.len() + } + /// Ever-pushed line count (= `first_id` + retained). Used by + /// the view anchor logic to clamp PgUp against the newest line. + pub fn total_logical_lines(&self) -> usize { + let g = self.inner.lock().unwrap(); + g.first_id + g.lines.len() + } + /// Logical id of the oldest retained line. A view anchor less + /// than this has been evicted — `window_from` snaps forward, + /// but the `run_tui` clamp can also observe it and nudge the + /// anchor forward itself so the `[PIN @N]` marker stays honest. + pub fn first_id(&self) -> usize { + self.inner.lock().unwrap().first_id + } + /// Convenience for clippy; not used by the TUI. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Install a `kres_core::io` printer that routes every +/// `async_println` through the TUI scrollback. Returns Ok even if +/// the printer slot is already filled — the first caller wins and we +/// don't want to abort TUI startup over a printer already installed +/// by an earlier rustyline attempt in the same process. +pub fn install_tui_printer(scrollback: Scrollback) { + // Replace unconditionally: the session may have installed a + // stdout-bootstrap printer earlier to serve `print_banner` and + // other pre-TUI messages; once alt screen is about to take over + // those stdout writes would blow up the frame, so the TUI + // scrollback takes ownership here. + kres_core::io::replace_printer(Box::new(move |s| { + scrollback.push(&s); + })); +} + +/// Simple stdout writer used by the default / --stdio paths as a +/// bootstrap printer. Installed before `print_banner` so every +/// `async_println` / migrated `println!` call reaches a real sink +/// from the first line. The rustyline path replaces this with its +/// `ExternalPrinter` once the editor finishes booting; --stdio +/// keeps this printer for the whole session so redirected output +/// (`kres --stdio … > out.txt`) captures everything. +pub fn install_stdout_printer() { + let _ = kres_core::io::install_printer(Box::new(|s| { + use std::io::Write as _; + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{s}"); + let _ = out.flush(); + })); +} + +/// Input-buffer state plus a history ring. Stage 2 adds Up/Down +/// recall and persistence to `~/.kres/history` so the TUI matches +/// rustyline's main QoL win. Stage 8 adds Ctrl-R reverse-i-search. +#[derive(Default)] +struct Input { + buf: String, + /// Cursor position in char (not byte) units so arrow-key moves + /// don't split UTF-8. Converted to a byte offset only at + /// insert/delete time. + cursor: usize, + /// Submitted lines, oldest first. Capped at [`HISTORY_CAP`] so a + /// long-running session doesn't grow unbounded. + history: Vec, + /// `None` = editing a fresh line; `Some(i)` = cursoring through + /// history, with `i` as the index into `history`. When the + /// operator types after a history move we drop back to `None` + /// (their edits live on a draft that isn't saved until submit). + hist_idx: Option, + /// Stashed draft while browsing history. Restored when the + /// operator walks past the newest entry (Down one past the end) + /// or hits Escape. Kept separate from `buf` so the line they + /// were typing doesn't get clobbered by an accidental Up press. + draft: String, + /// `Some` while Ctrl-R is active. Contains the in-progress + /// query and the index of the matching history entry (if any). + /// Typing appends to the query; Ctrl-R steps to the next older + /// match; Enter accepts; Esc / Ctrl-C cancels. + search: Option, + /// Most recently killed text (Ctrl-W / Ctrl-U / Ctrl-K). + /// Ctrl-Y pastes it at the cursor. Not history-persisted — + /// matches rustyline, which keeps kill state per-session. + kill_buffer: String, +} + +/// Ctrl-R reverse-i-search state. `query` is the substring the +/// operator has typed; `match_idx` is the history-ring position of +/// the most-recent entry containing it, or None when nothing +/// matches. Recomputed on every query change; stepped (older) +/// on every additional Ctrl-R press. +#[derive(Default)] +struct SearchState { + query: String, + match_idx: Option, +} + +/// Cap the in-memory history ring. Matches rustyline's default +/// max_history_size. Anything older is dropped on push so long +/// sessions stay bounded. +const HISTORY_CAP: usize = 1_000; + +impl Input { + fn byte_pos(&self) -> usize { + self.buf + .char_indices() + .nth(self.cursor) + .map(|(i, _)| i) + .unwrap_or(self.buf.len()) + } + fn char_len(&self) -> usize { + self.buf.chars().count() + } + /// Called whenever the operator performs an edit (insert, delete, + /// backspace): abandon history-browse mode so subsequent edits + /// live on the current line instead of on the historical entry. + fn leave_history(&mut self) { + self.hist_idx = None; + } + fn insert(&mut self, c: char) { + self.leave_history(); + let p = self.byte_pos(); + self.buf.insert(p, c); + self.cursor += 1; + } + /// Insert a literal newline. Called from the Shift/Alt-Enter + /// path so the operator can compose multi-line prompts inline + /// without going through /edit. Storage is just `'\n'` in + /// `buf`; the renderer splits on newline at draw time. + fn newline(&mut self) { + self.insert('\n'); + } + + /// Insert a whole string at the cursor. Used by bracketed + /// paste — terminals send the pasted payload as one event so + /// embedded newlines don't fire the Enter handler partway + /// through. Normalises `\r\n` and bare `\r` to `\n` so pastes + /// from clipboards that carry DOS line endings land with a + /// sane shape in `buf`. + fn insert_str(&mut self, s: &str) { + self.leave_history(); + if s.is_empty() { + return; + } + let normalised = s.replace("\r\n", "\n").replace('\r', "\n"); + let n = normalised.chars().count(); + let p = self.byte_pos(); + self.buf.insert_str(p, &normalised); + self.cursor += n; + } + + /// Convert a char-index into a byte-index for `self.buf`. Past + /// the end returns `buf.len()` — matches `byte_pos` semantics. + fn char_to_byte(&self, char_idx: usize) -> usize { + self.buf + .char_indices() + .nth(char_idx) + .map(|(b, _)| b) + .unwrap_or(self.buf.len()) + } + + /// Kill the word behind the cursor (Ctrl-W). Word = run of + /// non-whitespace after any trailing whitespace. Killed text is + /// stored in `kill_buffer` so a subsequent Ctrl-Y yanks it. + fn kill_prev_word(&mut self) { + self.leave_history(); + if self.cursor == 0 { + return; + } + let chars: Vec = self.buf.chars().collect(); + let mut i = self.cursor; + while i > 0 && chars[i - 1].is_whitespace() { + i -= 1; + } + while i > 0 && !chars[i - 1].is_whitespace() { + i -= 1; + } + let killed: String = chars[i..self.cursor].iter().collect(); + let start_byte = self.char_to_byte(i); + let end_byte = self.byte_pos(); + self.buf.drain(start_byte..end_byte); + self.cursor = i; + self.kill_buffer = killed; + } + + /// Kill from the cursor back to the start of the current + /// logical line (Ctrl-U). In a single-line buffer that's the + /// start of the buffer; when there are embedded newlines the + /// kill stops at the previous `\n` so lines above aren't + /// touched — matches rustyline / bash convention. + fn kill_to_line_start(&mut self) { + self.leave_history(); + if self.cursor == 0 { + return; + } + let chars: Vec = self.buf.chars().collect(); + let mut start = self.cursor; + while start > 0 && chars[start - 1] != '\n' { + start -= 1; + } + let killed: String = chars[start..self.cursor].iter().collect(); + let start_byte = self.char_to_byte(start); + let end_byte = self.byte_pos(); + self.buf.drain(start_byte..end_byte); + self.cursor = start; + self.kill_buffer = killed; + } + + /// Kill from the cursor to the end of the current logical line + /// (Ctrl-K). Stops at the next `\n`; does not remove the + /// newline itself so a Ctrl-K on a blank inner line leaves the + /// empty line intact. + fn kill_to_line_end(&mut self) { + self.leave_history(); + let chars: Vec = self.buf.chars().collect(); + if self.cursor >= chars.len() { + return; + } + let mut end = self.cursor; + while end < chars.len() && chars[end] != '\n' { + end += 1; + } + if end == self.cursor { + return; + } + let killed: String = chars[self.cursor..end].iter().collect(); + let start_byte = self.byte_pos(); + let end_byte = self.char_to_byte(end); + self.buf.drain(start_byte..end_byte); + self.kill_buffer = killed; + } + + /// Yank the kill buffer at the cursor (Ctrl-Y). No-op when the + /// kill buffer is empty so a blind Ctrl-Y on session start + /// doesn't insert a stale paste. + fn yank(&mut self) { + self.leave_history(); + if self.kill_buffer.is_empty() { + return; + } + let yanked = self.kill_buffer.clone(); + let n = yanked.chars().count(); + let p = self.byte_pos(); + self.buf.insert_str(p, &yanked); + self.cursor += n; + } + + /// Transpose the two characters around the cursor (Ctrl-T). + /// At end-of-buffer, swap the last two chars without advancing — + /// matches readline's "fix-the-typo-you-just-made" convention. + fn transpose_chars(&mut self) { + self.leave_history(); + let n = self.char_len(); + if n < 2 || self.cursor == 0 { + return; + } + let (left, right) = if self.cursor >= n { + (self.cursor - 2, self.cursor - 1) + } else { + (self.cursor - 1, self.cursor) + }; + let mut chars: Vec = self.buf.chars().collect(); + chars.swap(left, right); + self.buf = chars.into_iter().collect(); + if self.cursor < n { + self.cursor += 1; + } + } + fn backspace(&mut self) { + self.leave_history(); + if self.cursor == 0 { + return; + } + self.cursor -= 1; + let p = self.byte_pos(); + self.buf.remove(p); + } + fn delete(&mut self) { + self.leave_history(); + if self.cursor >= self.char_len() { + return; + } + let p = self.byte_pos(); + self.buf.remove(p); + } + fn take(&mut self) -> String { + self.cursor = 0; + self.hist_idx = None; + self.draft.clear(); + std::mem::take(&mut self.buf) + } + /// Record a submitted line. No-op for empty lines (parity with + /// rustyline's add_history_entry when the trimmed form is + /// empty); also dedupes against the most-recent entry so + /// repeated Enter on the same prompt doesn't clutter the ring. + fn record(&mut self, line: &str) { + if line.trim().is_empty() { + return; + } + if self.history.last().is_some_and(|prev| prev == line) { + return; + } + self.history.push(line.to_string()); + if self.history.len() > HISTORY_CAP { + let drop = self.history.len() - HISTORY_CAP; + self.history.drain(0..drop); + } + } + /// Ctrl-R: either enter search mode (first press) or step to + /// the next-older match (subsequent presses). Does nothing when + /// history is empty. + fn search_start_or_step(&mut self) { + if self.history.is_empty() { + return; + } + if self.search.is_none() { + self.search = Some(SearchState::default()); + self.recompute_search_match(); + return; + } + // Step: find the next older entry that also matches the + // current query. + let (query, cur_match) = { + let Some(ref s) = self.search else { return }; + (s.query.clone(), s.match_idx) + }; + let next_idx = + cur_match.and_then(|cur| (0..cur).rev().find(|&i| self.history[i].contains(&query))); + if let Some(ref mut s) = self.search { + if next_idx.is_some() { + s.match_idx = next_idx; + } + // Leave the old match visible when there's nothing + // older — matches bash/rustyline's behaviour of + // "stuck at oldest match" rather than silently clearing. + } + } + /// Append `c` to the current search query and re-search for the + /// newest matching history entry. + fn search_push(&mut self, c: char) { + if let Some(ref mut s) = self.search { + s.query.push(c); + } + self.recompute_search_match(); + } + fn search_pop(&mut self) { + if let Some(ref mut s) = self.search { + s.query.pop(); + } + self.recompute_search_match(); + } + /// Recompute `match_idx` from scratch, scanning newest-to-oldest + /// for the first history entry containing the query. + fn recompute_search_match(&mut self) { + let query = match self.search { + Some(ref s) => s.query.clone(), + None => return, + }; + let idx = self.history.iter().rposition(|h| h.contains(&query)); + if let Some(ref mut s) = self.search { + s.match_idx = idx; + } + } + /// Enter while in search mode — copy the matched entry into the + /// input buffer so a subsequent Enter submits it, and exit + /// search mode. When no match is current the query is dropped + /// silently and the buffer is left as-is. + fn search_accept(&mut self) { + if let Some(s) = self.search.take() { + if let Some(idx) = s.match_idx { + self.buf = self.history[idx].clone(); + self.cursor = self.char_len(); + } + } + } + /// Esc / Ctrl-C while searching — abandon the query and return + /// to whatever the operator had in the buffer before. + fn search_cancel(&mut self) { + self.search = None; + } + + /// Up: move the cursor up one line when the buffer has + /// embedded newlines, falling through to `history_prev` only + /// when the cursor is on the first source line (nothing to + /// move up to). Column is preserved and clamped to the target + /// line's length, matching vim / most editors. + fn move_up(&mut self) { + let chars: Vec = self.buf.chars().collect(); + // Start of current source line. + let mut line_start = self.cursor; + while line_start > 0 && chars[line_start - 1] != '\n' { + line_start -= 1; + } + if line_start == 0 { + self.history_prev(); + return; + } + let col = self.cursor - line_start; + let prev_line_end = line_start - 1; // the '\n' + let mut prev_line_start = prev_line_end; + while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' { + prev_line_start -= 1; + } + let prev_line_len = prev_line_end - prev_line_start; + self.cursor = prev_line_start + col.min(prev_line_len); + } + + /// Down: move the cursor down one line when there's a source + /// line below, else fall through to `history_next`. + fn move_down(&mut self) { + let chars: Vec = self.buf.chars().collect(); + let mut line_end = self.cursor; + while line_end < chars.len() && chars[line_end] != '\n' { + line_end += 1; + } + if line_end >= chars.len() { + self.history_next(); + return; + } + let mut line_start = self.cursor; + while line_start > 0 && chars[line_start - 1] != '\n' { + line_start -= 1; + } + let col = self.cursor - line_start; + let next_line_start = line_end + 1; + let mut next_line_end = next_line_start; + while next_line_end < chars.len() && chars[next_line_end] != '\n' { + next_line_end += 1; + } + let next_line_len = next_line_end - next_line_start; + self.cursor = next_line_start + col.min(next_line_len); + } + + /// Up-arrow: step one entry backwards in history. Stashes the + /// draft on the first press so a later Down can restore it. + fn history_prev(&mut self) { + if self.history.is_empty() { + return; + } + let new_idx = match self.hist_idx { + None => { + // Leaving draft mode — stash whatever we were typing. + self.draft = self.buf.clone(); + self.history.len() - 1 + } + Some(0) => 0, // already at oldest, clamp + Some(i) => i - 1, + }; + self.hist_idx = Some(new_idx); + self.buf = self.history[new_idx].clone(); + self.cursor = self.char_len(); + } + /// Down-arrow: step one forward. Past the newest entry restores + /// the stashed draft and drops out of browse mode. + fn history_next(&mut self) { + let Some(i) = self.hist_idx else { return }; + if i + 1 >= self.history.len() { + // Walked off the end; restore draft. + self.hist_idx = None; + self.buf = std::mem::take(&mut self.draft); + self.cursor = self.char_len(); + return; + } + self.hist_idx = Some(i + 1); + self.buf = self.history[i + 1].clone(); + self.cursor = self.char_len(); + } +} + +/// Load the persisted history file (one entry per line). Missing or +/// unreadable files are not an error — a first-run session starts +/// with an empty ring. +pub fn load_history(path: &std::path::Path) -> Vec { + let Ok(text) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + text.lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect() +} + +/// Persist the history ring to `path`, creating the parent dir if +/// missing. Failures are swallowed — losing history on shutdown is +/// not important enough to abort teardown and leave the terminal in +/// raw mode. +pub fn save_history(path: &std::path::Path, history: &[String]) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let body: String = history + .iter() + .flat_map(|line| [line.as_str(), "\n"]) + .collect(); + let _ = std::fs::write(path, body); +} + +/// RAII guard: leaves raw mode and the alternate screen on drop. +/// Constructed *before* the event loop so a panic unwinding through +/// the loop restores the user's terminal instead of leaving it in +/// raw mode. +struct TuiGuard; + +impl TuiGuard { + fn enter() -> io::Result { + enable_raw_mode()?; + let mut out = io::stdout(); + // BracketedPaste makes a terminal deliver pastes as a + // single `Event::Paste(String)` rather than a barrage of + // KeyPress events — crucial because a multi-line paste + // would otherwise submit at the first Enter. Capture is + // best-effort; terminals without support leave it off and + // the old one-key-per-char behaviour keeps working. + execute!( + out, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; + Ok(Self) + } +} + +impl Drop for TuiGuard { + fn drop(&mut self) { + let mut out = io::stdout(); + let _ = execute!( + out, + DisableBracketedPaste, + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = disable_raw_mode(); + let _ = out.flush(); + } +} + +/// Suspend the TUI (leave alt screen + raw mode), run `$EDITOR` on +/// a tempfile, then restore the TUI so the event loop can resume +/// drawing. Mirrors [`Session::cmd_edit`] at session.rs:2515 but +/// lives on the TUI thread because that's the thread that owns the +/// terminal. Doing the handoff from the main loop would need a +/// cross-thread choreography (tell the TUI to suspend, wait for it, +/// run the editor, signal resume); handling it here keeps the +/// terminal-ownership boundary a single thread's concern. +/// +/// Returns the non-empty trimmed tempfile contents on success, or +/// None when the editor errored, the file was empty, or the user +/// aborted. Any resume error is logged via `async_eprintln!` and +/// the caller is expected to bail — at that point the terminal is +/// wedged and the session has to tear down. +fn run_editor_handoff() -> Option { + // Leave the TUI before the child runs so the editor paints on a + // normal-screen terminal with raw mode off. The subsequent + // re-entry rebuilds the TUI frame from scratch via + // `terminal.clear()` in the caller. + let mut out = io::stdout(); + let _ = execute!(out, LeaveAlternateScreen, DisableMouseCapture); + let _ = disable_raw_mode(); + let _ = out.flush(); + + let editor_cmd = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + let tmp = std::env::temp_dir().join(format!( + "kres-edit-{}-{}.md", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + let _ = std::fs::write(&tmp, ""); + let status = std::process::Command::new(&editor_cmd).arg(&tmp).status(); + // Trust the tempfile contents regardless of editor exit code — + // `:wq!` forced-quit and Esc-save-without-quit shouldn't drop + // the typed prompt. Only a spawn failure skips the read. + let content = match status { + Ok(_) => std::fs::read_to_string(&tmp).ok(), + Err(e) => { + kres_core::async_eprintln!("/edit: editor spawn failed: {e}"); + None + } + }; + let _ = std::fs::remove_file(&tmp); + + // Re-enter the TUI. Raw mode + alt screen are restored so the + // next draw() fires into a clean frame. + if enable_raw_mode().is_err() { + kres_core::async_eprintln!("/edit: re-entering raw mode failed"); + return None; + } + if execute!(out, EnterAlternateScreen, EnableMouseCapture).is_err() { + kres_core::async_eprintln!("/edit: re-entering alt screen failed"); + return None; + } + let trimmed = content + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + if trimmed.is_none() { + kres_core::async_eprintln!("/edit: empty, nothing submitted"); + } + trimmed +} + +/// Status-line callback: given the current terminal width, produce +/// the single-line summary to paint above the input bar. Kept +/// generic so the TUI doesn't import the TaskManager type directly — +/// `Session::run` closes over a shared snapshot cell (populated by a +/// tokio background task) and passes a capturing closure that reads +/// it synchronously. No `block_on` from the crossterm thread. +pub type StatusFn = Box String + Send>; + +/// Width of the "> " prompt prefix painted on the very first visual +/// row of the input widget. Subsequent visual rows (either from +/// `\n` or from wrap) start at column 0. +const PROMPT_PREFIX: usize = 2; + +/// Compute `(total_visual_rows, cursor_row, cursor_col)` for the +/// input prompt given `width` (the paragraph text-area width, i.e. +/// box width minus borders). The cursor column already includes +/// the `"> "` prefix when the cursor sits on the first visual row. +/// +/// Pulled out as a pure function so (a) the draw closure can size +/// the input box and park the cursor from one walk of the buffer, +/// and (b) a unit test can exercise every branch without spinning +/// up a ratatui backend. +fn compute_input_layout(buf: &str, cursor: usize, width: u16) -> (u16, u16, u16) { + // Degenerate terminals (1-wide) park the cursor at origin and + // claim one row; the frame will look bad but nothing panics. + let width = (width as usize).max(PROMPT_PREFIX + 1); + let mut row: u16 = 0; + let mut chars_on_row: usize = 0; + let mut on_first_src_line = true; + let mut cur_row: u16 = 0; + let mut cur_col: u16 = PROMPT_PREFIX as u16; + let mut passed_cursor = false; + let effective_cap = |r: u16, first: bool| -> usize { + if r == 0 && first { + width.saturating_sub(PROMPT_PREFIX) + } else { + width + } + }; + for (i, c) in buf.chars().enumerate() { + if i == cursor { + let prefix = if row == 0 && on_first_src_line { + PROMPT_PREFIX + } else { + 0 + }; + cur_row = row; + cur_col = (prefix + chars_on_row) as u16; + passed_cursor = true; + } + if c == '\n' { + row += 1; + chars_on_row = 0; + on_first_src_line = false; + continue; + } + let cap = effective_cap(row, on_first_src_line); + if chars_on_row + 1 > cap { + row += 1; + chars_on_row = 1; + } else { + chars_on_row += 1; + } + } + if !passed_cursor { + let prefix = if row == 0 && on_first_src_line { + PROMPT_PREFIX + } else { + 0 + }; + cur_row = row; + cur_col = (prefix + chars_on_row) as u16; + } + (row + 1, cur_row, cur_col) +} + +/// Entry point used by `Session::run`. Owns the terminal and the +/// event pump; blocks until Ctrl-D / channel-close. Runs on a +/// `spawn_blocking` thread exactly like the rustyline path. +/// +/// `tx` — where submitted lines go (commands + prompts, same format +/// as the plain path emits). +/// +/// `ack_rx` — currently ignored. Kept in the signature so call sites +/// match the rustyline path and a future stage can coordinate +/// $EDITOR handoff. +pub fn run_tui( + tx: mpsc::UnboundedSender, + _ack_rx: mpsc::UnboundedReceiver<()>, + scrollback: Scrollback, + status_fn: StatusFn, + history_path: Option, +) -> io::Result<()> { + let _guard = TuiGuard::enter()?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let mut input = Input::default(); + if let Some(ref p) = history_path { + input.history = load_history(p); + } + // Scrollback view state. `None` = follow mode (always show the + // tail). `Some(i)` = pinned at absolute line index `i` — new + // lines pushed at the tail don't shift what the operator is + // looking at, because the window is `[anchor..anchor+rows]` + // regardless of total length. PgDn / End restore follow by + // clearing back to None. + let mut view_anchor: Option = None; + // Track what we showed last draw so PgUp / PgDn can step by one + // page (= visible rows - 1 for continuity). Initialise to a + // sensible default in case the first key press fires before the + // first draw tick. + let mut last_scrollback_rows: usize = 20; + + // Cap on the input box height — past this, the input scrolls + // rather than pushing the scrollback pane off-screen. Rustyline + // does the same when multi-line composition outgrows the + // terminal. + const INPUT_MAX_ROWS: u16 = 10; + loop { + terminal.draw(|f| { + let size = f.area(); + // Grow the input box to fit both explicit `\n` newlines + // AND soft-wrapped long lines. Two-pass layout: compute + // with an assumed width, then use the real one. Since + // the box width is (terminal width - 0 borders padding), + // a single pass with `size.width - 2` (the inner width + // of the bordered box) is enough — the visual row count + // doesn't depend on box height, only on width, and the + // layout split only depends on box height. + let inner_width = size.width.saturating_sub(2); + let (buf_rows, cur_row, cur_col) = + compute_input_layout(&input.buf, input.cursor, inner_width); + // +2 for borders. Capped so a monster paste doesn't + // push the scrollback off-screen; if the buffer needs + // more rows than INPUT_MAX_ROWS the overflow scrolls + // within the Paragraph and the cursor clamps to the + // bottom edge. + let input_rows = (buf_rows + 2).min(INPUT_MAX_ROWS); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // scrollback + Constraint::Length(1), // status line + Constraint::Length(input_rows), // input (borders + text) + ]) + .split(size); + + let scrollback_rows = chunks[0].height as usize; + last_scrollback_rows = scrollback_rows; + let total = scrollback.total_logical_lines(); + let first = scrollback.first_id(); + // Clamp anchor into the currently-valid logical range. + // Three nudges: + // 1. anchor < first_id → line has been evicted. Snap + // to `first_id` so the view sits on the oldest + // retained line instead of silently following. + // 2. anchor + rows >= total → the tail would already + // be on-screen from this anchor; drop the pin so + // we follow again (and the [PIN] marker clears). + // 3. anchor >= total → buffer was cleared under us; + // follow. + if let Some(a) = view_anchor { + // Cases 2 and 3 both drop the pin — the tail would + // already be on-screen (so no point pinning) or the + // buffer was cleared under us (so there's nothing + // left to pin to). Case 1 snaps up to the oldest + // retained line instead of silently following. + if a >= total || a + scrollback_rows >= total { + view_anchor = None; + } else if a < first { + view_anchor = Some(first); + } + } + let window = match view_anchor { + Some(anchor_id) => scrollback.window_from(anchor_id, scrollback_rows), + None => scrollback.window(scrollback_rows, 0), + }; + // Pad the top with blank lines so content is anchored + // to the bottom of the pane (against the status line), + // matching terminal scrollback convention. Without this, + // a short buffer renders at the top of the pane and + // leaves an empty gap between the latest line and the + // status row — the operator submits /followup, gets a + // one-line response, and sees it isolated high in the + // pane with nothing near the prompt, reading as if the + // command produced nothing. + let pad = scrollback_rows.saturating_sub(window.len()); + let body: Vec = (0..pad) + .map(|_| Line::from("")) + .chain(window.into_iter().map(Line::from)) + .collect(); + let output = Paragraph::new(body).wrap(Wrap { trim: false }); + f.render_widget(output, chunks[0]); + + // When scrolled away from the bottom, prefix the + // status with a short marker so the operator isn't + // confused by new lines appearing off-screen. Shows + // the anchored top line's index so they can tell how + // far back they've walked. + let status_text = status_fn(chunks[1].width as usize); + let status_text = if let Some(a) = view_anchor { + format!("[PIN @{a}] {status_text}") + } else { + status_text + }; + let status = Paragraph::new(Line::from(Span::styled( + status_text, + Style::default().add_modifier(Modifier::REVERSED), + ))); + f.render_widget(status, chunks[1]); + + // Search mode: render `(reverse-i-search)'query': match` + // in place of the normal prompt. The regular buffer is + // untouched — a search-accept moves the match into the + // buffer and exits search mode before the next draw. + if let Some(ref s) = input.search { + let match_line = s + .match_idx + .map(|i| input.history[i].clone()) + .unwrap_or_else(|| "(no match)".to_string()); + let search_prompt = format!("(reverse-i-search)`{}': {match_line}", s.query); + let widget = Paragraph::new(Line::from(Span::raw(search_prompt))) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(widget, chunks[2]); + return; + } + // Render each \n-separated segment as a visual line, + // with "> " bolted onto the first one so the prompt + // marker stays visible regardless of how tall the box + // has grown. A completely empty buffer still renders + // one visible row so the cursor has somewhere to sit. + let segments: Vec<&str> = if input.buf.is_empty() { + vec![""] + } else { + input.buf.split('\n').collect() + }; + let prompt_lines: Vec = segments + .iter() + .enumerate() + .map(|(i, s)| { + if i == 0 { + Line::from(vec![ + Span::styled("> ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(s.to_string()), + ]) + } else { + Line::from(Span::raw(s.to_string())) + } + }) + .collect(); + let prompt = Paragraph::new(prompt_lines) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(prompt, chunks[2]); + + // Cursor row/col came from compute_input_layout above; + // translate to absolute terminal coords. `+1` on each + // axis to step inside the border. Clamp to the + // box's bottom-right so an oversized paste doesn't + // park the cursor in the status area. + let cx = chunks[2].x + 1 + cur_col; + let cy = chunks[2].y + 1 + cur_row; + let right_edge = chunks[2].x + chunks[2].width - 1; + let bottom_edge = chunks[2].y + chunks[2].height - 1; + f.set_cursor_position((cx.min(right_edge), cy.min(bottom_edge))); + })?; + + // Event poll: 100ms matches the REPL's ambient poll cadence. + // A shorter poll would waste CPU; longer lags status-bar and + // scrollback updates the operator can see. + // + // Keep the pump responsive to async_println traffic — drive + // a redraw on every tick even without a key event so + // background writers show up promptly. + if !event::poll(Duration::from_millis(100))? { + // Silent tick — status_fn is driven by a shared cell + // refreshed on a tokio task, so the next draw() already + // has fresh data without blocking here. + continue; + } + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + // Search mode swallows most input: typable chars + // extend the query, Backspace trims it, Ctrl-R + // steps to the next match, Enter accepts, Esc / + // Ctrl-C cancels. Any other key exits search mode + // without accepting so the main handler sees it. + if input.search.is_some() { + match (key.code, key.modifiers) { + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + input.search_start_or_step(); + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) | (KeyCode::Esc, _) => { + input.search_cancel(); + } + (KeyCode::Enter, _) => { + input.search_accept(); + } + (KeyCode::Backspace, _) => { + input.search_pop(); + } + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + input.search_push(c); + } + _ => { + // Arrow keys, PgUp/Dn, etc. cancel + // search rather than mysteriously + // doing nothing. + input.search_cancel(); + } + } + continue; + } + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + // Ctrl-C: when the input has content, clear + // it (matches the rustyline behaviour of + // discarding the line). When the buffer is + // empty, the operator means "cancel the + // running tasks" — but crossterm's raw mode + // clears ISIG, so the kernel won't generate + // SIGINT for us. Raise it manually so the + // tokio `signal::ctrl_c` handler in + // Session::run wakes up and runs its drain + + // cancel + persist sequence. + if input.buf.is_empty() { + // SAFETY: kill(pid, SIGINT) is a thread- + // safe POSIX call. Target our own pid + // (not the process group): the group + // includes child processes like + // semcode-mcp, which would catch the + // SIGINT and exit, racing the in-process + // tokio handler and tearing down the + // session before it could drain. Errors + // here would mean the kernel can't + // deliver — unrecoverable for the cancel + // path, so swallow. + unsafe { + libc::kill(libc::getpid(), libc::SIGINT); + } + } else { + input.buf.clear(); + input.cursor = 0; + } + } + (KeyCode::Char('d'), KeyModifiers::CONTROL) if input.buf.is_empty() => { + // Ctrl-D on empty buffer = EOF. Persist the + // history ring before returning so the next + // session sees this run's entries. Dropping + // `tx` would do the same exit-wise, but we + // return explicitly so a later stage can + // distinguish operator-driven shutdown from + // an internal error. + if let Some(ref p) = history_path { + save_history(p, &input.history); + } + return Ok(()); + } + (KeyCode::Enter, m) + if m.contains(KeyModifiers::SHIFT) || m.contains(KeyModifiers::ALT) => + { + // Shift-Enter / Alt-Enter insert a literal + // newline so the operator can compose a + // multi-line prompt without going through + // $EDITOR. Matches the rustyline bindings + // at session.rs:3797-3802. + input.newline(); + } + (KeyCode::Enter, _) + if input.buf.ends_with('\\') && !input.buf.ends_with("\\\\") => + { + // Backslash-Enter: shell-style line + // continuation. Eat the trailing `\` and + // insert a newline. `\\` (escaped + // backslash) is respected so an operator + // who genuinely wants a prompt ending in + // `\` can type it by doubling. + // + // The check is against the whole buffer, + // not the cursor position, so hitting + // Enter after a mid-buffer edit still + // submits as normal unless the very last + // char is a lone `\`. + input.buf.pop(); + input.cursor = input.cursor.saturating_sub(1); + input.newline(); + } + (KeyCode::Enter, _) => { + // Record BEFORE take() so `input.history` + // has the line appended, then ship it. The + // submit channel and history are decoupled: + // tx.send failure still leaves the entry in + // memory so a later save_history call picks + // it up. + let line = input.buf.clone(); + input.record(&line); + let _ = input.take(); + // Echo the submitted line into the scrollback + // *before* the tx.send. The main REPL loop + // might be blocked on a long-running command + // (slow agent turn, /summary streaming, etc.), + // so without the echo the operator has no + // confirmation that their slash command was + // accepted — the input just disappears and + // stays silent until the main loop drains + // the current work. Empty submissions + // (blank Enter) skip the echo to avoid a + // blank "> " line in scrollback. + if !line.is_empty() { + scrollback.push(&format!("> {line}")); + } + // Any submission drops the scroll pin and + // snaps to follow mode. Otherwise a + // scrolled-back operator who fires /todo + // or /followup sees nothing: the command + // output lands at the tail, the pinned + // view stays on older content, and the + // response is invisible until they also + // press End/Ctrl-End. Operators who meant + // to keep reading the old page wouldn't + // be submitting in the first place. + view_anchor = None; + // `/edit` on its own submits a prompt via + // $EDITOR. In rustyline mode this is the + // cmd_edit path; here we do the same work + // on the TUI thread because the main loop + // doesn't own the terminal. + if line.trim() == "/edit" { + if let Some(text) = run_editor_handoff() { + input.record(&text); + if tx.send(text).is_err() { + if let Some(ref p) = history_path { + save_history(p, &input.history); + } + return Ok(()); + } + } + terminal.clear()?; + continue; + } + if tx.send(line).is_err() { + if let Some(ref p) = history_path { + save_history(p, &input.history); + } + return Ok(()); + } + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + // Ctrl-R: start reverse history search. + // The search-mode branch at the top of the + // match handles subsequent Ctrl-R presses + // by stepping to the next-older match. + input.search_start_or_step(); + } + // ── Emacs-style cursor / history aliases ── + (KeyCode::Char('a'), KeyModifiers::CONTROL) => input.cursor = 0, + (KeyCode::Char('e'), KeyModifiers::CONTROL) => { + input.cursor = input.char_len(); + } + (KeyCode::Char('b'), KeyModifiers::CONTROL) => { + input.cursor = input.cursor.saturating_sub(1); + } + (KeyCode::Char('f'), KeyModifiers::CONTROL) => { + let n = input.char_len(); + if input.cursor < n { + input.cursor += 1; + } + } + (KeyCode::Char('p'), KeyModifiers::CONTROL) => input.move_up(), + (KeyCode::Char('n'), KeyModifiers::CONTROL) => input.move_down(), + // ── Kill / yank / transpose ── + (KeyCode::Char('w'), KeyModifiers::CONTROL) => input.kill_prev_word(), + (KeyCode::Char('u'), KeyModifiers::CONTROL) => input.kill_to_line_start(), + (KeyCode::Char('k'), KeyModifiers::CONTROL) => input.kill_to_line_end(), + (KeyCode::Char('y'), KeyModifiers::CONTROL) => input.yank(), + (KeyCode::Char('t'), KeyModifiers::CONTROL) => input.transpose_chars(), + // ── Clear scrollback view + redraw ── + (KeyCode::Char('l'), KeyModifiers::CONTROL) => { + // Ctrl-L: drop any pin and force a full + // repaint. Doesn't drop the buffer — + // operators who want to purge scrollback + // can /clear. + view_anchor = None; + terminal.clear()?; + } + (KeyCode::Char('g'), KeyModifiers::CONTROL) => { + // Ctrl-G: open $EDITOR on a scratch + // file — matches the rustyline binding at + // session.rs:3792. Stashes whatever is in + // the input buffer so the draft isn't lost + // if the operator aborts the editor. + if let Some(text) = run_editor_handoff() { + input.record(&text); + if tx.send(text).is_err() { + if let Some(ref p) = history_path { + save_history(p, &input.history); + } + return Ok(()); + } + // Any submission drops the scroll pin, + // same as the Enter path — the editor + // handoff is just a more elaborate + // Enter and the operator expects to + // see the response. + view_anchor = None; + } + terminal.clear()?; + } + (KeyCode::Up, _) => input.move_up(), + (KeyCode::Down, _) => input.move_down(), + (KeyCode::PageUp, _) => { + // Step by (rows - 1) so one line of + // context carries over between pages, + // matching the convention `less` uses. + // First PgUp converts follow → pin at + // (total - rows - step); further PgUps + // decrement the anchor. Anchor values are + // logical line ids; clamp at `first_id` so + // we can't page past the oldest retained + // line into evicted-id territory. + let step = last_scrollback_rows.saturating_sub(1).max(1); + let total = scrollback.total_logical_lines(); + let first = scrollback.first_id(); + let raw = match view_anchor { + Some(a) => a.saturating_sub(step), + None => total + .saturating_sub(last_scrollback_rows) + .saturating_sub(step), + }; + view_anchor = Some(raw.max(first)); + } + (KeyCode::PageDown, _) => { + let step = last_scrollback_rows.saturating_sub(1).max(1); + view_anchor = match view_anchor { + Some(a) => { + let next = a.saturating_add(step); + let total = scrollback.total_logical_lines(); + if next + last_scrollback_rows >= total { + None + } else { + Some(next) + } + } + None => None, + }; + } + // Ctrl+Home / Ctrl+End jump to the top and + // bottom of scrollback. Bare Home/End are + // reserved for input-line cursor moves (below) + // so the common cursor-to-start / cursor-to-end + // gestures keep working. Top = first retained + // logical id so the PIN marker shows a real, + // visible line. + (KeyCode::Home, m) if m.contains(KeyModifiers::CONTROL) => { + view_anchor = Some(scrollback.first_id()); + } + (KeyCode::End, m) if m.contains(KeyModifiers::CONTROL) => { + view_anchor = None; + } + (KeyCode::Backspace, _) => input.backspace(), + (KeyCode::Delete, _) => input.delete(), + (KeyCode::Left, _) => { + input.cursor = input.cursor.saturating_sub(1); + } + (KeyCode::Right, _) => { + let n = input.char_len(); + if input.cursor < n { + input.cursor += 1; + } + } + (KeyCode::Home, _) => input.cursor = 0, + (KeyCode::End, _) => input.cursor = input.char_len(), + (KeyCode::Esc, _) => { + // Matches the convention "Esc drops out of + // history browse" — restore the stashed + // draft. A no-op when not browsing. + if input.hist_idx.is_some() { + input.hist_idx = None; + input.buf = std::mem::take(&mut input.draft); + input.cursor = input.char_len(); + } + } + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + input.insert(c); + } + _ => {} + } + } + Event::Paste(text) => { + // BracketedPaste: dump the whole chunk into the + // buffer as one operation. If search mode is active, + // the query extends by the pasted content too — + // that's handy for pasting a partial command to + // find it in history. + if let Some(ref mut s) = input.search { + s.query.push_str(&text); + input.recompute_search_match(); + } else { + input.insert_str(&text); + } + } + Event::Mouse(me) => { + // Wheel scroll walks the scrollback view the same + // way PgUp/PgDn do, just three lines per tick so a + // single flick of the wheel moves a comfortable + // amount without overshooting short output. Mouse + // position is ignored — the wheel always targets + // the scrollback pane, which is the only pane + // that's scrollable. Shift+select still works in + // most terminals to bypass mouse capture for text + // copy. + match me.kind { + MouseEventKind::ScrollUp => { + let total = scrollback.total_logical_lines(); + let first = scrollback.first_id(); + let raw = match view_anchor { + Some(a) => a.saturating_sub(3), + None => total.saturating_sub(last_scrollback_rows).saturating_sub(3), + }; + view_anchor = Some(raw.max(first)); + } + MouseEventKind::ScrollDown => { + view_anchor = match view_anchor { + Some(a) => { + let next = a.saturating_add(3); + let total = scrollback.total_logical_lines(); + if next + last_scrollback_rows >= total { + None + } else { + Some(next) + } + } + None => None, + }; + } + _ => {} + } + } + Event::Resize(_, _) => { + // Next draw() picks up the new size automatically. + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scrollback_respects_cap() { + let sb = Scrollback::new(); + for i in 0..(SCROLLBACK_CAP + 50) { + sb.push(&format!("line {i}")); + } + let tail = sb.tail(10); + assert_eq!(tail.len(), 10); + // The last line pushed must survive; the first must not. + assert_eq!( + tail.last().unwrap(), + &format!("line {}", SCROLLBACK_CAP + 49) + ); + assert_eq!(sb.len(), SCROLLBACK_CAP); + // Oldest retained logical id is 50 (we pushed 10050 total). + assert_eq!(sb.first_id(), 50); + // Pull the oldest retained via a wide-enough tail call. + let full = sb.tail(SCROLLBACK_CAP); + assert_eq!(full.first().unwrap(), "line 50"); + } + + #[test] + fn scrollback_window_offset_walks_back() { + let sb = Scrollback::new(); + for i in 0..50 { + sb.push(&format!("l{i}")); + } + // Offset 0 → last 10 lines (l40..l49). + let tail = sb.window(10, 0); + assert_eq!(tail.first().unwrap(), "l40"); + assert_eq!(tail.last().unwrap(), "l49"); + // Offset 5 → window ends at l44, so last 10 ending there + // = l35..l44. + let mid = sb.window(10, 5); + assert_eq!(mid.first().unwrap(), "l35"); + assert_eq!(mid.last().unwrap(), "l44"); + // Offset past the oldest entry returns an empty window + // rather than panicking. + let over = sb.window(10, 500); + assert!(over.is_empty()); + } + + #[test] + fn scrollback_window_from_anchors_to_logical_id() { + let sb = Scrollback::new(); + for i in 0..20 { + sb.push(&format!("l{i}")); + } + // Pin at logical id 5, show 6 rows → l5..l10. + let pinned = sb.window_from(5, 6); + assert_eq!(pinned.len(), 6); + assert_eq!(pinned.first().unwrap(), "l5"); + assert_eq!(pinned.last().unwrap(), "l10"); + // Push more lines — the pinned view shouldn't shift. + for i in 20..30 { + sb.push(&format!("l{i}")); + } + let still_pinned = sb.window_from(5, 6); + assert_eq!(still_pinned, pinned); + // Anchor past newest → empty (not a panic). + let past = sb.window_from(9999, 5); + assert!(past.is_empty()); + } + + #[test] + fn scrollback_pin_survives_eviction() { + // Fill past the cap so the ring drains and `first_id` + // advances; the pinned window's contents must stay the + // same before and after push-past-cap traffic continues. + let sb = Scrollback::new(); + for i in 0..SCROLLBACK_CAP { + sb.push(&format!("l{i}")); + } + // Pin mid-buffer (anchor 500), page size 6 → l500..l505. + let before = sb.window_from(500, 6); + assert_eq!(before.first().unwrap(), "l500"); + assert_eq!(before.last().unwrap(), "l505"); + assert_eq!(sb.first_id(), 0); + // Now push another 300 lines — triggers eviction of the + // oldest 300. first_id advances; anchor id 500 still maps + // to the same content (just at a different vec index). + for i in SCROLLBACK_CAP..(SCROLLBACK_CAP + 300) { + sb.push(&format!("l{i}")); + } + assert_eq!(sb.first_id(), 300); + let after = sb.window_from(500, 6); + assert_eq!(after, before); + // An anchor that's been evicted snaps forward to the + // oldest retained line rather than returning garbage from + // an off-by-first_id vec index. + let evicted = sb.window_from(100, 6); + assert_eq!(evicted.first().unwrap(), "l300"); + } + + #[test] + fn total_logical_lines_includes_evicted() { + let sb = Scrollback::new(); + for i in 0..(SCROLLBACK_CAP + 50) { + sb.push(&format!("l{i}")); + } + assert_eq!(sb.first_id(), 50); + assert_eq!(sb.len(), SCROLLBACK_CAP); + assert_eq!(sb.total_logical_lines(), SCROLLBACK_CAP + 50); + } + + #[test] + fn scrollback_splits_embedded_newlines() { + let sb = Scrollback::new(); + sb.push("alpha\nbeta\ngamma"); + assert_eq!(sb.tail(10), vec!["alpha", "beta", "gamma"]); + } + + #[test] + fn input_insert_and_backspace() { + let mut i = Input::default(); + i.insert('h'); + i.insert('i'); + assert_eq!(i.buf, "hi"); + assert_eq!(i.cursor, 2); + i.backspace(); + assert_eq!(i.buf, "h"); + assert_eq!(i.cursor, 1); + i.backspace(); + i.backspace(); // underflow-safe + assert_eq!(i.buf, ""); + assert_eq!(i.cursor, 0); + } + + #[test] + fn input_preserves_utf8_on_cursor_moves() { + let mut i = Input::default(); + for c in "héllo".chars() { + i.insert(c); + } + assert_eq!(i.char_len(), 5); + i.cursor = 0; + assert_eq!(i.byte_pos(), 0); + i.cursor = 2; + assert_eq!(&i.buf[i.byte_pos()..], "llo"); + i.delete(); + assert_eq!(i.buf, "hélo"); + } + + #[test] + fn input_take_resets_state() { + let mut i = Input::default(); + i.insert('x'); + i.insert('y'); + let got = i.take(); + assert_eq!(got, "xy"); + assert_eq!(i.buf, ""); + assert_eq!(i.cursor, 0); + } + + #[test] + fn move_up_stays_in_buffer_when_multiline() { + let mut i = Input::default(); + i.insert_str("one\ntwo\nthree"); + // Cursor at end of "three" (char 13). + assert_eq!(i.cursor, 13); + i.move_up(); + // Column 5 clamped to "two"'s length 3 → cursor at 7. + assert_eq!(i.cursor, 7); + i.move_up(); + // Column 3 on "one" (length 3) → cursor at 3. + assert_eq!(i.cursor, 3); + } + + #[test] + fn move_up_on_first_line_falls_through_to_history() { + let mut i = Input::default(); + i.record("earlier"); + i.insert_str("draft"); + assert_eq!(i.cursor, 5); + i.move_up(); + // history_prev kicked in — buffer replaced with historical entry. + assert_eq!(i.buf, "earlier"); + assert_eq!(i.hist_idx, Some(0)); + } + + #[test] + fn move_down_advances_within_multiline() { + let mut i = Input::default(); + i.insert_str("alpha\nbeta"); + i.cursor = 2; // inside "alpha", column 2 + i.move_down(); + // Column 2 on "beta" → cursor at char 8 (6 = '\n' index + 1, + 2). + assert_eq!(i.cursor, 8); + } + + #[test] + fn move_down_on_last_line_falls_through_to_history() { + let mut i = Input::default(); + i.record("only"); + i.record("other"); + i.insert_str("draft"); + // Walk history back, then move_down — should step to next entry. + i.history_prev(); + i.history_prev(); + assert_eq!(i.buf, "only"); + // move_down on single-line buffer with history → history_next. + i.move_down(); + assert_eq!(i.buf, "other"); + } + + #[test] + fn insert_str_normalises_crlf_and_cr() { + let mut i = Input::default(); + i.insert_str("alpha\r\nbeta\rgamma"); + assert_eq!(i.buf, "alpha\nbeta\ngamma"); + // Cursor ended at char-length of the normalised string. + assert_eq!(i.cursor, i.char_len()); + } + + #[test] + fn insert_str_appends_at_cursor() { + let mut i = Input::default(); + i.insert_str("hello"); + i.cursor = 2; // cursor between 'e' and 'l' + i.insert_str("XY"); + assert_eq!(i.buf, "heXYllo"); + assert_eq!(i.cursor, 4); // after the XY + } + + #[test] + fn layout_empty_buffer_is_one_row_cursor_at_prefix() { + let (rows, r, c) = compute_input_layout("", 0, 80); + assert_eq!(rows, 1); + assert_eq!((r, c), (0, PROMPT_PREFIX as u16)); + } + + #[test] + fn layout_single_newline_gives_two_rows() { + // "a\nb" with cursor past end → row 1, col 1 (no prefix on row 1). + let (rows, r, c) = compute_input_layout("a\nb", 3, 80); + assert_eq!(rows, 2); + assert_eq!((r, c), (1, 1)); + } + + #[test] + fn layout_wraps_long_line_when_over_width() { + // width = 10 cols, first row cap = width - prefix = 8. + // 10 'a's → row 0 fills to 8, then 2 wrap onto row 1. + let buf = "a".repeat(10); + let (rows, r, c) = compute_input_layout(&buf, 10, 10); + assert_eq!(rows, 2); + // Cursor is past the end: row 1, col 2. + assert_eq!((r, c), (1, 2)); + } + + #[test] + fn layout_cursor_mid_wrapped_segment() { + // width 10 (cap 8 on row 0), buf = 12 'b's. + // Row 0: cols 2..10 (8 chars). Row 1: cols 0..4 (4 chars). + // Cursor at char 5 → still on row 0, col = 2 + 5 = 7. + let buf = "b".repeat(12); + let (_, r, c) = compute_input_layout(&buf, 5, 10); + assert_eq!((r, c), (0, 7)); + } + + #[test] + fn kill_prev_word_removes_run_and_saves_to_kill_buffer() { + let mut i = Input::default(); + for c in "hello world".chars() { + i.insert(c); + } + i.kill_prev_word(); + assert_eq!(i.buf, "hello "); + assert_eq!(i.kill_buffer, "world"); + assert_eq!(i.cursor, 7); + // Second Ctrl-W eats the trailing whitespace + "hello". + i.kill_prev_word(); + assert_eq!(i.buf, ""); + assert_eq!(i.kill_buffer, "hello "); + } + + #[test] + fn kill_to_line_start_stops_at_prev_newline() { + let mut i = Input::default(); + for c in "line1\nhello world".chars() { + i.insert(c); + } + // Cursor is after "world" (at char_len). + i.kill_to_line_start(); + assert_eq!(i.buf, "line1\n"); + assert_eq!(i.kill_buffer, "hello world"); + } + + #[test] + fn kill_to_line_end_stops_at_next_newline() { + let mut i = Input::default(); + for c in "hello world\nnext".chars() { + i.insert(c); + } + // Move cursor to after "hello " (char idx 6). + i.cursor = 6; + i.kill_to_line_end(); + assert_eq!(i.buf, "hello \nnext"); + assert_eq!(i.kill_buffer, "world"); + } + + #[test] + fn yank_inserts_kill_buffer() { + let mut i = Input::default(); + for c in "foo bar".chars() { + i.insert(c); + } + i.kill_prev_word(); + assert_eq!(i.buf, "foo "); + i.yank(); + assert_eq!(i.buf, "foo bar"); + // Yank again at cursor — doubles the text. + i.yank(); + assert_eq!(i.buf, "foo barbar"); + } + + #[test] + fn transpose_swaps_chars_around_cursor() { + let mut i = Input::default(); + for c in "ab".chars() { + i.insert(c); + } + // Cursor at end (2) → readline's "fix last typo" → swap last two. + i.transpose_chars(); + assert_eq!(i.buf, "ba"); + assert_eq!(i.cursor, 2); + // Reset and try mid-buffer: "abcd" cursor at 2 (between b and c). + let mut j = Input::default(); + for c in "abcd".chars() { + j.insert(c); + } + j.cursor = 2; + j.transpose_chars(); + assert_eq!(j.buf, "acbd"); + assert_eq!(j.cursor, 3); + // Cursor at 0 → no-op. + let mut k = Input::default(); + for c in "ab".chars() { + k.insert(c); + } + k.cursor = 0; + k.transpose_chars(); + assert_eq!(k.buf, "ab"); + } + + #[test] + fn backslash_continuation_strips_slash_and_adds_newline() { + // Simulate the run_tui handler for bare Enter with a buffer + // ending in `\`: pop the slash, bump the cursor back, insert + // a newline. The handler is inline in the key match; this + // test exercises the state transitions the handler performs. + let mut i = Input::default(); + i.insert_str("foo\\"); + assert!(i.buf.ends_with('\\') && !i.buf.ends_with("\\\\")); + i.buf.pop(); + i.cursor = i.cursor.saturating_sub(1); + i.newline(); + assert_eq!(i.buf, "foo\n"); + assert_eq!(i.cursor, 4); + } + + #[test] + fn double_backslash_does_not_continue() { + // `\\` (two trailing slashes) means "literal backslash" + // not "line continuation". The run_tui handler guards with + // `!buf.ends_with("\\\\")` so plain Enter submits instead. + let i: Input = { + let mut i = Input::default(); + i.insert_str("foo\\\\"); + i + }; + assert!(i.buf.ends_with("\\\\")); + } + + #[test] + fn input_newline_preserves_cursor_and_buf() { + let mut i = Input::default(); + i.insert('a'); + i.insert('b'); + i.newline(); + i.insert('c'); + // Buffer now reads "ab\nc" with cursor at 4 (after 'c'). + assert_eq!(i.buf, "ab\nc"); + assert_eq!(i.cursor, 4); + // Backspace past the newline: 'c' is removed, then '\n'. + i.backspace(); + assert_eq!(i.buf, "ab\n"); + i.backspace(); + assert_eq!(i.buf, "ab"); + } + + #[test] + fn history_record_skips_empty_and_dedupes() { + let mut i = Input::default(); + i.record("foo"); + i.record(" "); // whitespace-only → skipped + i.record(""); + i.record("foo"); // exact dup of prior → skipped + i.record("bar"); + assert_eq!(i.history, vec!["foo".to_string(), "bar".to_string()]); + } + + #[test] + fn history_up_stashes_draft_and_down_restores() { + let mut i = Input::default(); + i.record("alpha"); + i.record("beta"); + // Type a draft, then press Up — draft must be stashed. + i.insert('d'); + i.insert('r'); + assert_eq!(i.buf, "dr"); + i.history_prev(); + assert_eq!(i.buf, "beta"); + assert_eq!(i.hist_idx, Some(1)); + i.history_prev(); + assert_eq!(i.buf, "alpha"); + // Clamp at the oldest entry. + i.history_prev(); + assert_eq!(i.buf, "alpha"); + // Down steps forward. + i.history_next(); + assert_eq!(i.buf, "beta"); + // Past the newest → restore draft and leave browse mode. + i.history_next(); + assert_eq!(i.buf, "dr"); + assert_eq!(i.hist_idx, None); + } + + #[test] + fn history_edit_drops_out_of_browse() { + let mut i = Input::default(); + i.record("alpha"); + i.history_prev(); + assert_eq!(i.hist_idx, Some(0)); + // A keystroke must abandon browse mode so later Up doesn't + // walk past what the operator is currently editing. + i.insert('!'); + assert_eq!(i.hist_idx, None); + assert_eq!(i.buf, "alpha!"); + } + + #[test] + fn history_file_roundtrips() { + let dir = std::env::temp_dir().join(format!( + "kres-tui-hist-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let path = dir.join("history"); + let entries = vec!["one".to_string(), "two".to_string(), "three".to_string()]; + save_history(&path, &entries); + let loaded = load_history(&path); + assert_eq!(loaded, entries); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn search_finds_newest_match_and_steps_older() { + let mut i = Input::default(); + i.record("git status"); + i.record("cargo build"); + i.record("cargo test"); + i.record("git log"); + // First Ctrl-R: newest match for empty query = newest entry. + i.search_start_or_step(); + let s = i.search.as_ref().unwrap(); + assert_eq!(s.match_idx, Some(3)); + // Type "git" — newest match containing "git" is "git log". + i.search_push('g'); + i.search_push('i'); + i.search_push('t'); + assert_eq!(i.search.as_ref().unwrap().match_idx, Some(3)); + // Step: next-older "git" match = "git status" at index 0. + i.search_start_or_step(); + assert_eq!(i.search.as_ref().unwrap().match_idx, Some(0)); + // Accept — buffer becomes "git status", search mode exits. + i.search_accept(); + assert_eq!(i.buf, "git status"); + assert!(i.search.is_none()); + } + + #[test] + fn search_no_match_leaves_buf_unchanged() { + let mut i = Input::default(); + i.record("alpha"); + i.record("beta"); + i.insert('x'); + assert_eq!(i.buf, "x"); + i.search_start_or_step(); + i.search_push('z'); // no entry contains 'z' + assert!(i.search.as_ref().unwrap().match_idx.is_none()); + i.search_accept(); + // No match → buf preserved, just search-mode cleared. + assert_eq!(i.buf, "x"); + assert!(i.search.is_none()); + } + + #[test] + fn search_cancel_leaves_everything_alone() { + let mut i = Input::default(); + i.record("alpha"); + i.insert('d'); + i.insert('r'); + i.search_start_or_step(); + i.search_push('a'); + assert_eq!(i.search.as_ref().unwrap().match_idx, Some(0)); + i.search_cancel(); + assert!(i.search.is_none()); + assert_eq!(i.buf, "dr"); + } + + #[test] + fn history_cap_trims_oldest() { + let mut i = Input::default(); + for n in 0..(HISTORY_CAP + 5) { + i.record(&format!("entry-{n}")); + } + assert_eq!(i.history.len(), HISTORY_CAP); + // Oldest entries were dropped; newest survive. + assert_eq!(i.history.first().unwrap(), "entry-5"); + assert_eq!( + i.history.last().unwrap(), + &format!("entry-{}", HISTORY_CAP + 4) + ); + } +} diff --git a/kres/src/main.rs b/kres/src/main.rs index 9608eec..30eb8f0 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -164,9 +164,24 @@ struct ReplArgs { /// Plain stdio mode: skip the persistent status-line scroll /// region and the DECSTBM fuss. Useful when the terminal is a /// pipe, a dumb tty, or something that doesn't handle scroll - /// regions (mosh, some tmux configs). + /// regions (mosh, some tmux configs). Also the mode to pick when + /// redirecting output to a file — `--tui` is ignored when + /// `--stdio` is set. #[arg(long, default_value_t = false)] stdio: bool, + /// Force the ratatui TUI on even when stdout isn't a TTY. + /// Useful for debugging the TUI rendering path from inside + /// `script` or other wrappers. Without `--tui` and without + /// `--no-tui`, the TUI is used automatically on a TTY. + /// `--stdio` takes precedence when both are set. + #[arg(long, default_value_t = false)] + tui: bool, + /// Force the rustyline-based prompt line — the pre-TUI default. + /// Left in as an escape hatch while the TUI shakes out; pass + /// this if the ratatui path misbehaves in your terminal. Wins + /// over `--tui` when both are set; `--stdio` wins over both. + #[arg(long, default_value_t = false)] + no_tui: bool, /// Render a summary from a prior run's report.md + /// findings.json and exit without starting the REPL. Uses the @@ -736,6 +751,14 @@ async fn run_repl(args: ReplArgs) -> Result<()> { results_dir: args.results.clone(), template_path: args.template.clone(), stdio: args.stdio, + // TUI is now the default on a TTY. Precedence: + // --stdio (plain) > --no-tui (rustyline) > --tui + // (force on) > auto (TUI when stdout is a TTY). + // Non-TTY stdout defaults to rustyline too, since ratatui + // needs a terminal to drive; --tui overrides that. + tui: !args.stdio + && !args.no_tui + && (args.tui || std::io::IsTerminal::is_terminal(&std::io::stdout())), workspace: args.workspace.clone(), persist_path, }; From c861ed8667faa5dd777a48625fca513c12077ba0 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Fri, 24 Apr 2026 13:11:10 -0700 Subject: [PATCH 53/76] tui: colourise slow-agent markdown in the scrollback pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI pane printed the slow-agent analysis body as one big wall of text — fenced code blocks, prose, and indented listings all ran together. Carry the block through a dedicated sink so the render path can style it, and keep the non-TUI paths unchanged: * kres_core::io grows a markdown-block sink slot alongside the existing printer slot. install_markdown_sink installs a TUI sink that brackets the body with MD_BLOCK_START / MD_BLOCK_END sentinel lines before pushing it into the scrollback. The non-TUI path never installs a sink, so async_println_markdown folds back to async_println and --stdio output is byte- identical. * report_reaped now emits the analysis body via async_println_markdown so the TUI sees a marked block while --stdio and rustyline paths keep their old plaintext. * tui::render_markdown_block is a tiny in-house renderer that emits ratatui Line/Span with fenced code in Cyan, fence markers in dim Cyan, 4-space-indented code in Cyan, and inline \`backtick\` spans in Cyan. Everything else is a plain Span. No new deps — tui-markdown 0.3 requires ratatui-core::Line and 0.2 requires ratatui 0.28, neither of which extend into our Vec without a cross-version shim. The render loop walks the windowed scrollback, pipes bracketed runs through render_markdown_block (drops sentinel lines), and renders everything else verbatim. A window slice that cuts through a bracketed region falls back to plain rendering for the visible half — acceptable for a first pass. Signed-off-by: Chris Mason --- kres-core/src/io.rs | 38 ++++++++ kres-repl/src/session.rs | 9 +- kres-repl/src/tui.rs | 197 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 7 deletions(-) diff --git a/kres-core/src/io.rs b/kres-core/src/io.rs index 8ea2028..7d41bcb 100644 --- a/kres-core/src/io.rs +++ b/kres-core/src/io.rs @@ -88,6 +88,44 @@ macro_rules! async_eprintln { }; } +// --------------------------------------------------------------- +// Markdown-block sink. +// +// The TUI render path can style a markdown body (fences, inline +// backticks) only if it knows which contiguous lines belong to +// the block. We expose a second sink callers opt into for those +// lines; in non-TUI contexts the sink is absent and the body +// falls through to `async_println` unchanged, so `--stdio > out` +// stays byte-for-byte identical to today. +// --------------------------------------------------------------- + +pub type MarkdownSinkFn = Box; + +fn md_slot() -> &'static RwLock> { + static SLOT: OnceLock>> = OnceLock::new(); + SLOT.get_or_init(|| RwLock::new(None)) +} + +/// Install the markdown-block sink. Only the TUI path calls this; +/// everyone else leaves the slot empty so `async_println_markdown` +/// folds into plain `async_println`. +pub fn install_markdown_sink(f: MarkdownSinkFn) -> Option { + let mut g = md_slot().write().unwrap(); + g.replace(f) +} + +/// Route a markdown body through the TUI sink when one is installed; +/// otherwise emit the body verbatim via `async_println`. The body +/// is a single multi-line string — do not split on newlines at the +/// call site. +pub fn async_println_markdown(body: &str) { + let g = md_slot().read().unwrap(); + match g.as_ref() { + Some(f) => f(body), + None => async_println(body.to_string()), + } +} + // --------------------------------------------------------------- // Active-streams registry. The REPL status line reads this to show // every in-flight Anthropic stream with its current token counts. diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 48aef66..de6466e 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -3722,9 +3722,16 @@ fn report_reaped(r: &kres_core::ReapedTask) { // know about /summary would see agent-traffic lines fly // past and then ... nothing. Full body on stdout matches // the 's behaviour. + // + // Route the body through the markdown sink so the TUI + // render path can style fenced code / inline backticks + // via tui_markdown. The sink is only installed by + // `install_tui_printer`; --stdio and rustyline paths + // leave it empty and fold straight back to + // `async_println`, so their output is unchanged. if !r.analysis.is_empty() { kres_core::async_eprintln!(""); - kres_core::async_eprintln!("{}", r.analysis); + kres_core::io::async_println_markdown(&r.analysis); kres_core::async_eprintln!(""); } } diff --git a/kres-repl/src/tui.rs b/kres-repl/src/tui.rs index 32475e8..d92ffb6 100644 --- a/kres-repl/src/tui.rs +++ b/kres-repl/src/tui.rs @@ -51,7 +51,7 @@ use crossterm::{ use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, Terminal, @@ -180,9 +180,102 @@ pub fn install_tui_printer(scrollback: Scrollback) { // other pre-TUI messages; once alt screen is about to take over // those stdout writes would blow up the frame, so the TUI // scrollback takes ownership here. + let sb_print = scrollback.clone(); kres_core::io::replace_printer(Box::new(move |s| { - scrollback.push(&s); + sb_print.push(&s); })); + // Second sink: markdown blocks (slow-agent analysis body). We + // bracket the block with sentinel lines the render path + // recognises and hands to tui_markdown. Non-TUI contexts don't + // install this sink, so `async_println_markdown` folds into + // `async_println` and `--stdio` stays byte-identical. + let sb_md = scrollback.clone(); + kres_core::io::install_markdown_sink(Box::new(move |body| { + sb_md.push(MD_BLOCK_START); + sb_md.push(body); + sb_md.push(MD_BLOCK_END); + })); +} + +/// Sentinels bracketing a markdown region in the scrollback. The +/// render loop strips them and hands the enclosed body to +/// `render_markdown_block`. Plain stdout never sees these — they +/// only enter the scrollback via the TUI-only sink in +/// `install_tui_printer`. +pub const MD_BLOCK_START: &str = "\x01kres-md-block-start\x01"; +pub const MD_BLOCK_END: &str = "\x01kres-md-block-end\x01"; + +/// Convert a markdown body into styled ratatui `Line`s. Small +/// in-house renderer — no deps — recognising the three shapes the +/// slow-agent actually uses: fenced code blocks (```…```, fence +/// markers in dim cyan, enclosed lines in cyan), 4-space-indented +/// code blocks (cyan), and inline `code` spans inside prose (cyan). +/// Everything else renders as a plain `Span`. The slow-agent body +/// is the only current caller, so scope is deliberately narrow — +/// no headings, lists, emphasis, or links. +pub fn render_markdown_block(body: &str) -> Vec> { + let code_style = Style::default().fg(Color::Cyan); + let fence_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::DIM); + let mut out: Vec> = Vec::new(); + let mut in_fence = false; + for line in body.split('\n') { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + // Fence delimiter (open or close). Dim-cyan the marker + // itself and flip fence state. + out.push(Line::from(Span::styled(line.to_string(), fence_style))); + in_fence = !in_fence; + continue; + } + if in_fence { + out.push(Line::from(Span::styled(line.to_string(), code_style))); + continue; + } + // 4-space-indented code block (outside a fence). + if line.starts_with(" ") && !line.trim().is_empty() { + out.push(Line::from(Span::styled(line.to_string(), code_style))); + continue; + } + // Prose line: scan for inline `backtick` spans. + out.push(Line::from(split_inline_code(line, code_style))); + } + out +} + +/// Turn a prose line into a vector of spans, styling anything +/// between matching backticks as code. Unmatched backticks fall +/// through as plain text. +fn split_inline_code(line: &str, code_style: Style) -> Vec> { + let mut spans: Vec> = Vec::new(); + let bytes = line.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + let Some(open_rel) = line[cursor..].find('`') else { + spans.push(Span::raw(line[cursor..].to_string())); + break; + }; + let open = cursor + open_rel; + // Plain text before the opening backtick. + if open > cursor { + spans.push(Span::raw(line[cursor..open].to_string())); + } + let Some(close_rel) = line[open + 1..].find('`') else { + // Unmatched: emit the rest as plain text, backtick and + // all, so the operator sees literally what the agent + // produced rather than silently losing characters. + spans.push(Span::raw(line[open..].to_string())); + break; + }; + let close = open + 1 + close_rel; + spans.push(Span::styled( + line[open + 1..close].to_string(), + code_style, + )); + cursor = close + 1; + } + spans } /// Simple stdout writer used by the default / --stdio paths as a @@ -938,10 +1031,38 @@ pub fn run_tui( // pane with nothing near the prompt, reading as if the // command produced nothing. let pad = scrollback_rows.saturating_sub(window.len()); - let body: Vec = (0..pad) - .map(|_| Line::from("")) - .chain(window.into_iter().map(Line::from)) - .collect(); + // Expand markdown regions: when we hit MD_BLOCK_START, + // gather lines until the matching MD_BLOCK_END, feed the + // joined body through tui_markdown, and splice its + // styled Lines in place of the raw lines. Marker lines + // are dropped. A window slice that cuts through a + // bracketed region (MD_START before the window, or + // MD_END after) falls back to plain rendering for the + // visible half — acceptable for a first pass. + let mut body: Vec = (0..pad).map(|_| Line::from("")).collect(); + let mut i = 0; + while i < window.len() { + if window[i] == MD_BLOCK_START { + let start = i + 1; + let end = window[start..] + .iter() + .position(|l| l == MD_BLOCK_END) + .map(|p| start + p) + .unwrap_or(window.len()); + let block = window[start..end].join("\n"); + body.extend(render_markdown_block(&block)); + // Skip past MD_END when we found one; otherwise + // we already consumed to the window tail. + i = if end < window.len() { end + 1 } else { end }; + } else if window[i] == MD_BLOCK_END { + // Dangling close marker (window started + // mid-block); swallow it. + i += 1; + } else { + body.push(Line::from(window[i].clone())); + i += 1; + } + } let output = Paragraph::new(body).wrap(Wrap { trim: false }); f.render_widget(output, chunks[0]); @@ -1401,6 +1522,70 @@ pub fn run_tui( mod tests { use super::*; + fn line_plain_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + fn line_styled_text(line: &Line<'_>, style: Style) -> String { + line.spans + .iter() + .filter(|s| s.style == style) + .map(|s| s.content.as_ref()) + .collect() + } + + #[test] + fn render_markdown_fenced_block_styles_every_line_including_markers() { + let body = "before\n```\ncode a\ncode b\n```\nafter"; + let lines = render_markdown_block(body); + let code_style = Style::default().fg(Color::Cyan); + let fence_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::DIM); + let texts: Vec = lines.iter().map(line_plain_text).collect(); + assert_eq!(texts, vec!["before", "```", "code a", "code b", "```", "after"]); + // Fence markers are dim-cyan, enclosed lines are plain cyan, + // prose lines carry no Cyan styling. + assert_eq!(lines[0].spans[0].style, Style::default(), "prose unstyled"); + assert_eq!(lines[1].spans[0].style, fence_style, "open fence dim"); + assert_eq!(lines[2].spans[0].style, code_style, "code a"); + assert_eq!(lines[3].spans[0].style, code_style, "code b"); + assert_eq!(lines[4].spans[0].style, fence_style, "close fence dim"); + assert_eq!(lines[5].spans[0].style, Style::default(), "prose after"); + } + + #[test] + fn render_markdown_inline_backticks_emit_mixed_spans() { + let body = "see `foo_bar()` and `x` for details"; + let lines = render_markdown_block(body); + assert_eq!(lines.len(), 1); + let code_style = Style::default().fg(Color::Cyan); + let code_text: String = line_styled_text(&lines[0], code_style); + assert_eq!(code_text, "foo_bar()x", "both backticked spans cyan"); + let full: String = line_plain_text(&lines[0]); + assert_eq!(full, "see foo_bar() and x for details"); + } + + #[test] + fn render_markdown_unmatched_backtick_passes_through() { + // Don't silently drop characters when the agent emits a + // stray backtick — surface what it produced. + let body = "backtick: ` alone"; + let lines = render_markdown_block(body); + let full: String = line_plain_text(&lines[0]); + assert_eq!(full, "backtick: ` alone"); + } + + #[test] + fn render_markdown_indented_code_outside_fence() { + let body = "prose\n fn foo() {}\nprose"; + let lines = render_markdown_block(body); + let code_style = Style::default().fg(Color::Cyan); + assert_eq!(lines[1].spans[0].style, code_style); + assert_eq!(lines[0].spans[0].style, Style::default()); + assert_eq!(lines[2].spans[0].style, Style::default()); + } + #[test] fn scrollback_respects_cap() { let sb = Scrollback::new(); From 2397599e040236f8477e15dae3606cc182ea088d Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Fri, 24 Apr 2026 13:18:34 -0700 Subject: [PATCH 54/76] tui/io: review follow-up on markdown sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from the review of c861ed8: 1. Stale tui_markdown references in two comments (install_tui printer at kres-repl/src/tui.rs:189 and the render loop around :1036) now name render_markdown_block, matching reality after the dep was removed. 2. install_markdown_sink in kres-core/src/io.rs is now install- if-absent (returns Err on collision) and the replace variant lives separately as replace_markdown_sink, mirroring the install_printer / replace_printer split in the same module. install_tui_printer switched to replace_markdown_sink deliberately — the TUI wants the replace semantics for the same reason it uses replace_printer a line earlier. 3. Pin the --stdio byte-identical promise with a test: async_println_markdown with no sink installed produces the same captured output as async_println, and never emits sentinels. Plus a test for the install_if_absent contract. 4. Drop the unused `bytes` binding in split_inline_code; only bytes.len() was read, which is identical to line.len(). Signed-off-by: Chris Mason --- kres-core/src/io.rs | 103 +++++++++++++++++++++++++++++++++++++++++-- kres-repl/src/tui.rs | 23 +++++----- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/kres-core/src/io.rs b/kres-core/src/io.rs index 7d41bcb..cf8516a 100644 --- a/kres-core/src/io.rs +++ b/kres-core/src/io.rs @@ -106,10 +106,22 @@ fn md_slot() -> &'static RwLock> { SLOT.get_or_init(|| RwLock::new(None)) } -/// Install the markdown-block sink. Only the TUI path calls this; -/// everyone else leaves the slot empty so `async_println_markdown` -/// folds into plain `async_println`. -pub fn install_markdown_sink(f: MarkdownSinkFn) -> Option { +/// Install the markdown-block sink. Install-if-absent: succeeds +/// when the slot is empty, returns `Err(f)` if another sink is +/// already installed. Mirrors [`install_printer`] so two crates +/// racing to bring up the TUI don't silently clobber each other. +pub fn install_markdown_sink(f: MarkdownSinkFn) -> Result<(), MarkdownSinkFn> { + let mut g = md_slot().write().unwrap(); + if g.is_some() { + return Err(f); + } + *g = Some(f); + Ok(()) +} + +/// Replace the markdown-block sink unconditionally, returning any +/// previously-installed handler. Mirrors [`replace_printer`]. +pub fn replace_markdown_sink(f: MarkdownSinkFn) -> Option { let mut g = md_slot().write().unwrap(); g.replace(f) } @@ -248,3 +260,86 @@ pub fn active_streams() -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // The printer/sink slots are process-global OnceLocks. Serialise + // the tests that touch them so cargo test -- --test-threads > 1 + // doesn't race. The tests also swap in a local printer and + // restore it afterward so other tests in the process aren't + // affected. + fn serial() -> &'static Mutex<()> { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| Mutex::new(())) + } + + fn capture_with_printer(f: F) -> Vec { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + let prev = replace_printer(Box::new(move |s| cap.lock().unwrap().push(s))); + f(); + // Restore whatever was there before (may be None). + match prev { + Some(p) => { + let _ = replace_printer(p); + } + None => { + *slot().write().unwrap() = None; + } + } + let g = captured.lock().unwrap(); + g.clone() + } + + #[test] + fn async_println_markdown_no_sink_matches_async_println_bytes() { + // The design promise of the markdown-block sink is that when + // no sink is installed (non-TUI paths), the body lands via + // async_println verbatim — no sentinels, no per-line split + // beyond what async_println does. This test pins that so a + // future refactor can't silently leak markers into --stdio. + let _g = serial().lock().unwrap(); + // Ensure the md sink slot is empty up front. + let _prev_md = replace_markdown_sink(Box::new(|_| {})); + *md_slot().write().unwrap() = None; + + let body = "line one\nline two\nline three"; + let via_plain = capture_with_printer(|| async_println(body.to_string())); + let via_markdown = capture_with_printer(|| async_println_markdown(body)); + assert_eq!(via_plain, via_markdown); + // Neither path should have produced sentinel lines. + for line in &via_plain { + assert!( + !line.starts_with('\x01'), + "sentinel leaked into non-sink path: {line:?}" + ); + } + } + + #[test] + fn install_markdown_sink_is_install_if_absent() { + // Mirrors install_printer's contract: second caller gets + // their handler back as Err, original stays installed. + let _g = serial().lock().unwrap(); + *md_slot().write().unwrap() = None; + + let first_mark: Arc> = Arc::new(Mutex::new(0)); + let fm = first_mark.clone(); + assert!( + install_markdown_sink(Box::new(move |_| { + *fm.lock().unwrap() = 1; + })) + .is_ok() + ); + // Second installer: must be rejected. + let second = install_markdown_sink(Box::new(|_| {})); + assert!(second.is_err(), "second install must bounce"); + // First sink is still the one that fires. + async_println_markdown("anything"); + assert_eq!(*first_mark.lock().unwrap(), 1); + *md_slot().write().unwrap() = None; + } +} diff --git a/kres-repl/src/tui.rs b/kres-repl/src/tui.rs index d92ffb6..2c8adda 100644 --- a/kres-repl/src/tui.rs +++ b/kres-repl/src/tui.rs @@ -186,11 +186,11 @@ pub fn install_tui_printer(scrollback: Scrollback) { })); // Second sink: markdown blocks (slow-agent analysis body). We // bracket the block with sentinel lines the render path - // recognises and hands to tui_markdown. Non-TUI contexts don't - // install this sink, so `async_println_markdown` folds into - // `async_println` and `--stdio` stays byte-identical. + // recognises and hands to `render_markdown_block`. Non-TUI + // contexts don't install this sink, so `async_println_markdown` + // folds into `async_println` and `--stdio` stays byte-identical. let sb_md = scrollback.clone(); - kres_core::io::install_markdown_sink(Box::new(move |body| { + kres_core::io::replace_markdown_sink(Box::new(move |body| { sb_md.push(MD_BLOCK_START); sb_md.push(body); sb_md.push(MD_BLOCK_END); @@ -249,9 +249,8 @@ pub fn render_markdown_block(body: &str) -> Vec> { /// through as plain text. fn split_inline_code(line: &str, code_style: Style) -> Vec> { let mut spans: Vec> = Vec::new(); - let bytes = line.as_bytes(); let mut cursor = 0usize; - while cursor < bytes.len() { + while cursor < line.len() { let Some(open_rel) = line[cursor..].find('`') else { spans.push(Span::raw(line[cursor..].to_string())); break; @@ -1033,12 +1032,12 @@ pub fn run_tui( let pad = scrollback_rows.saturating_sub(window.len()); // Expand markdown regions: when we hit MD_BLOCK_START, // gather lines until the matching MD_BLOCK_END, feed the - // joined body through tui_markdown, and splice its - // styled Lines in place of the raw lines. Marker lines - // are dropped. A window slice that cuts through a - // bracketed region (MD_START before the window, or - // MD_END after) falls back to plain rendering for the - // visible half — acceptable for a first pass. + // joined body through `render_markdown_block`, and + // splice its styled Lines in place of the raw lines. + // Marker lines are dropped. A window slice that cuts + // through a bracketed region (MD_START before the + // window, or MD_END after) falls back to plain rendering + // for the visible half — acceptable for a first pass. let mut body: Vec = (0..pad).map(|_| Line::from("")).collect(); let mut i = 0; while i < window.len() { From 5ccf5a4ec9b9509e83bb7836a84fa1c7a997b75c Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 05:14:46 -0700 Subject: [PATCH 55/76] kres-repl: exit on idle when stdout is not a terminal The --turns 0 stop condition (goal met, no-progress streak, or no-goal-agent batch finished) leaves the REPL open waiting for further operator input. A redirected/piped invocation has no operator on the other end, so the process sits forever after the work is done. Add ReplConfig::exit_on_idle, set it from main.rs to !stdout.is_terminal(), and have the reaper's --turns 0 stop branch set turns_exhausted and cancel root_shutdown when the flag is on. Mirrors the existing --turns N exit path so summary.txt is auto-rendered before teardown. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 29 ++++++++++++++++++++++++++--- kres/src/main.rs | 5 +++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index de6466e..89b98df 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -77,6 +77,13 @@ pub struct ReplConfig { /// resumed by re-invoking kres with the same `--results DIR`. /// None disables persistence (no-op writes). pub persist_path: Option, + /// When true, exit the REPL once the work-stop condition fires + /// (`--turns 0` goal-met / no-progress / no-goal-batch-finished), + /// instead of staying open waiting for further operator input. + /// Defaulted to `!stdout.is_terminal()` from main.rs so a piped + /// invocation (`kres ... > out.txt`) terminates after the + /// turns stop, matching the existing `--turns N` exit path. + pub exit_on_idle: bool, } impl Default for ReplConfig { @@ -93,6 +100,7 @@ impl Default for ReplConfig { tui: false, workspace: PathBuf::from("."), persist_path: None, + exit_on_idle: false, } } } @@ -862,6 +870,7 @@ impl Session { let stop_notify_for_reaper = self.stop_notify.clone(); let turns_limit = self.cfg.turns_limit; let follow_followups = self.cfg.follow_followups; + let exit_on_idle = self.cfg.exit_on_idle; // §16: findings-signature watchdog. Every successful merge // increments `quiescent` when the merged list's signature // matches the prior one; five consecutive no-change merges @@ -1666,9 +1675,12 @@ impl Session { "no new findings for {no_new_findings_streak} consecutive run(s)" ) }; - kres_core::async_eprintln!( - "\n=== --turns 0: {reason} — REPL staying open; submit another prompt, /summary, or /quit ===" - ); + let suffix = if exit_on_idle { + "exiting (stdout is not a terminal)" + } else { + "REPL staying open; submit another prompt, /summary, or /quit" + }; + kres_core::async_eprintln!("\n=== --turns 0: {reason} — {suffix} ==="); // Flip InProgress → Pending before the drain // so the deferred list is complete; an item // mid-run at goal-met time shouldn't silently @@ -1692,6 +1704,17 @@ impl Session { ); } turns0_stop_announced = true; + // Non-tty stdout: exit on first stop, same as + // `--turns N`. Set turns_exhausted so the + // post-loop summary auto-renders, then cancel + // root_shutdown to break the REPL select on + // root_shutdown.cancelled(). + if exit_on_idle { + turns_exhausted_for_reaper + .store(true, std::sync::atomic::Ordering::Release); + mgr_for_reaper.root_shutdown().cancel(); + break; + } } } // Persist session state at the end of every reaper diff --git a/kres/src/main.rs b/kres/src/main.rs index 30eb8f0..77b2f66 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -761,6 +761,11 @@ async fn run_repl(args: ReplArgs) -> Result<()> { && (args.tui || std::io::IsTerminal::is_terminal(&std::io::stdout())), workspace: args.workspace.clone(), persist_path, + // Piped/redirected stdout has no operator on the other end, + // so once the work-stop condition fires there is no one to + // type the next prompt. Match the existing `--turns N` exit + // path and quit the REPL when stdout isn't a tty. + exit_on_idle: !std::io::IsTerminal::is_terminal(&std::io::stdout()), }; let mut session = Session::new(mgr, cfg).await; // Resume from a prior session.json ONLY when `--resume` was From 6a4c199bfec90fa384eb9eb87f8c4d8320e82a40 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 05:42:03 -0700 Subject: [PATCH 56/76] kres-repl: surface filename and subsystem in export metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit metadata.yaml's only file pointers were buried inside relevant_symbols and relevant_file_sections list items, so a reader scanning a tree of finding folders had no top-level signal for which file each finding lived under. Emit a canonical filename: at the top of metadata.yaml, populated from the first relevant_symbol (falling back to the first relevant_file_section, then empty). Reserve a subsystem: slot in the same block — the Finding schema has no subsystem field today, so it renders empty for now and a follow-up todo will derive it from filename via a path-prefix rule. Signed-off-by: Chris Mason --- configs/prompts/export-metadata.yaml | 2 + kres-repl/src/export.rs | 55 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/configs/prompts/export-metadata.yaml b/configs/prompts/export-metadata.yaml index b69fe0e..47e3de0 100644 --- a/configs/prompts/export-metadata.yaml +++ b/configs/prompts/export-metadata.yaml @@ -3,6 +3,8 @@ id: {{id}} title: {{title}} severity: {{!severity}} status: {{!status}} +filename: {{filename}} +subsystem: {{subsystem}} git: sha: {{git_sha}} subject: {{git_subject}} diff --git a/kres-repl/src/export.rs b/kres-repl/src/export.rs index 3102959..e381d36 100644 --- a/kres-repl/src/export.rs +++ b/kres-repl/src/export.rs @@ -397,6 +397,22 @@ fn severity_str(s: Severity) -> &'static str { } } +/// Pick the canonical top-level filename for a finding. +/// Order: first relevant symbol → first relevant file section → "". +fn primary_filename(f: &Finding) -> String { + if let Some(sym) = f.relevant_symbols.first() { + if !sym.filename.is_empty() { + return sym.filename.clone(); + } + } + if let Some(sec) = f.relevant_file_sections.first() { + if !sec.filename.is_empty() { + return sec.filename.clone(); + } + } + String::new() +} + fn status_str(s: Status) -> &'static str { match s { Status::Active => "active", @@ -441,6 +457,18 @@ fn build_context(f: &Finding, git: &GitHead) -> Ctx { c.insert("status".into(), Value::Scalar(status_str(f.status).into())); c.insert("git_sha".into(), Value::Scalar(git.sha.clone())); c.insert("git_subject".into(), Value::Scalar(git.subject.clone())); + // Canonical top-level filename: prefer the first relevant symbol's + // file (named code site), fall back to the first relevant file + // section, then empty. The template emits the field unconditionally + // so an empty value renders as `filename: ""` — readers can grep + // for unattributed findings without writing a tri-state check. + let primary_filename = primary_filename(f); + c.insert("filename".into(), Value::Scalar(primary_filename)); + // Subsystem is not currently in the Finding schema; leave the slot + // present so readers and downstream tools see a consistent shape. + // A later todo will derive this from `filename` via a path-prefix + // rule. + c.insert("subsystem".into(), Value::Scalar(String::new())); // Use first_seen_at when the finding carries one; fall back to // wall-clock now for legacy records (pre-first_seen_at findings.json @@ -1053,6 +1081,33 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn embedded_template_emits_top_level_filename_and_blank_subsystem() { + // Sample finding has a relevant_symbol at drivers/net/x.c — + // the canonical top-level `filename:` should reflect that. + // `subsystem:` is intentionally blank for now (later todo). + let out = render( + METADATA_TEMPLATE, + &build_context(&finding_sample(), &git_sample()), + ); + assert!( + out.contains("filename: \"drivers/net/x.c\""), + "missing filename: {out}" + ); + assert!(out.contains("subsystem: \"\"\n"), "missing subsystem: {out}"); + } + + #[test] + fn primary_filename_falls_back_to_file_sections_then_empty() { + // No symbols, but a file section: filename comes from there. + let mut f = finding_sample(); + f.relevant_symbols.clear(); + assert_eq!(primary_filename(&f), "drivers/net/x.c"); + // Neither symbols nor sections: empty string. + f.relevant_file_sections.clear(); + assert_eq!(primary_filename(&f), ""); + } + #[test] fn embedded_template_renders_introduced_by_when_set() { let mut f = finding_sample(); From e3b83b16d78749f5a0a45525643308dc43b745f8 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 08:13:49 -0700 Subject: [PATCH 57/76] kres-repl: gate code_output absolute paths on the consent store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persist_code_output rejected every absolute path outright, so a coding task asked to write $DIR/summary.md (where $DIR is an absolute folder named in the prompt) had no way to put the file there: emitting an absolute path got dropped, and emitting a relative path landed at /summary.md instead of the bug folder. Operators were forced to either re-launch kres with --workspace pointed at the target dir or work around it with a bash followup. Match the rule edit_file already uses (kres-agents/src/tools.rs:resolve_workspace): accept an absolute path when it resolves under the workspace OR under a directory the operator named in a prompt this session — consent::grant_paths_from_text already canonicalises and stores those mentions. Keep rejecting any path that contains '..' regardless of rooting; that's how a malformed reply would try to slip past both gates. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 151 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 11 deletions(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 89b98df..320d579 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -3547,11 +3547,25 @@ pub async fn build_orchestrator( } /// Print a one-line summary of a reaped task. -/// Write code_output files emitted by a Coding-mode task to -/// `/`. Rejects absolute paths and traversal -/// segments (`..`) so a malformed model reply can't drop files -/// outside the workspace root. Each file is written with a -/// tmp + rename so a crash doesn't leave a partial artifact. +/// Write code_output files emitted by a Coding-mode task. +/// +/// Path handling, mirroring the rule `edit_file` already uses for +/// outside-workspace edits (kres-agents/src/tools.rs:resolve_workspace): +/// +/// * Relative paths land at `/` — same default +/// `` rooting that's served the in-tree coding flow. +/// * Absolute paths are accepted ONLY when they resolve under the +/// workspace OR under a directory the operator named in a prompt +/// this session (granted via `consent::grant_paths_from_text`). +/// This is what lets a triage prompt that names an absolute bug +/// folder receive `summary.md` writes there directly, without +/// dropping write-anywhere across the FS. +/// * `..` traversal segments are always rejected — they don't make +/// sense in either rooting and are how a malformed reply would +/// try to escape both the workspace and the consent gate. +/// +/// Each file is written with a tmp + rename so a crash doesn't leave +/// a partial artifact. /// One applied (or attempted) CodeEdit. The reaper folds these /// back into the task's analysis trailer so a failure ("old_string /// not found", "ambiguous match") is visible to the NEXT slow-agent @@ -3671,21 +3685,36 @@ async fn persist_code_output(workspace: &Path, task_name: &str, files: &[kres_co kres_core::async_eprintln!("[coding] create {} failed: {e}", base.display()); return; } + let ws_canon = base.canonicalize().unwrap_or_else(|_| base.clone()); let mut wrote = 0usize; for f in files { let rel = std::path::Path::new(&f.path); - if rel.is_absolute() - || rel - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) + if rel + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) { kres_core::async_eprintln!( - "[coding] rejecting suspicious path '{}' (absolute or contains '..')", + "[coding] rejecting suspicious path '{}' (contains '..')", f.path ); continue; } - let out = base.join(rel); + let out = if rel.is_absolute() { + let allowed = rel.starts_with(&ws_canon) + || kres_core::consent::get() + .map(|s| s.is_allowed(rel)) + .unwrap_or(false); + if !allowed { + kres_core::async_eprintln!( + "[coding] rejecting absolute path '{}' (outside workspace and no consent on file — mention the containing directory in a prompt to grant write access)", + f.path + ); + continue; + } + rel.to_path_buf() + } else { + base.join(rel) + }; if let Some(parent) = out.parent() { if let Err(e) = tokio::fs::create_dir_all(parent).await { kres_core::async_eprintln!("[coding] mkdir {} failed: {e}", parent.display()); @@ -4242,4 +4271,104 @@ mod tests { fn truncate_ellipsises_long() { assert_eq!(truncate("abcdef", 3), "abc…"); } + + fn code_output_tmp_dir(nonce: &str) -> std::path::PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let mut p = std::env::temp_dir(); + p.push(format!( + "kres-code-output-{}-{}-{:x}", + nonce, + std::process::id(), + nanos + )); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[tokio::test] + async fn code_output_relative_lands_under_workspace() { + let ws = code_output_tmp_dir("rel"); + let files = vec![kres_core::CodeFile { + path: "summary.md".into(), + content: "hello".into(), + purpose: String::new(), + }]; + persist_code_output(&ws, "task1", &files).await; + let written = std::fs::read_to_string(ws.join("summary.md")).unwrap(); + assert_eq!(written, "hello"); + std::fs::remove_dir_all(&ws).ok(); + } + + #[tokio::test] + async fn code_output_absolute_outside_workspace_without_consent_is_rejected() { + // Fresh consent store with NO grants. + let _ = kres_core::consent::install(Arc::new(kres_core::ConsentStore::new())); + if let Some(s) = kres_core::consent::get() { + s.clear(); + } + let ws = code_output_tmp_dir("abs-rejected-ws"); + let outside = code_output_tmp_dir("abs-rejected-out"); + let target = outside.join("summary.md"); + let files = vec![kres_core::CodeFile { + path: target.display().to_string(), + content: "nope".into(), + purpose: String::new(), + }]; + persist_code_output(&ws, "task1", &files).await; + assert!( + !target.exists(), + "consent gate should have blocked the absolute write" + ); + std::fs::remove_dir_all(&ws).ok(); + std::fs::remove_dir_all(&outside).ok(); + } + + #[tokio::test] + async fn code_output_absolute_with_consent_writes_through() { + let _ = kres_core::consent::install(Arc::new(kres_core::ConsentStore::new())); + let store = kres_core::consent::get().expect("consent installed"); + store.clear(); + let ws = code_output_tmp_dir("abs-allowed-ws"); + let bug_dir = code_output_tmp_dir("abs-allowed-bug"); + // Operator-mention equivalent: grant the bug dir. + store + .grant_from_mention(&bug_dir) + .expect("grant existing dir"); + let target = bug_dir.join("summary.md"); + let files = vec![kres_core::CodeFile { + path: target.display().to_string(), + content: "triage body".into(), + purpose: "triage summary".into(), + }]; + persist_code_output(&ws, "task1", &files).await; + let written = std::fs::read_to_string(&target).expect("file written"); + assert_eq!(written, "triage body"); + // Make sure we did NOT also write a copy under the workspace. + let basename = target.file_name().unwrap(); + assert!(!ws.join(basename).exists()); + store.clear(); + std::fs::remove_dir_all(&ws).ok(); + std::fs::remove_dir_all(&bug_dir).ok(); + } + + #[tokio::test] + async fn code_output_parentdir_traversal_is_rejected() { + let ws = code_output_tmp_dir("parent"); + let files = vec![kres_core::CodeFile { + path: "../escape.md".into(), + content: "no".into(), + purpose: String::new(), + }]; + persist_code_output(&ws, "task1", &files).await; + let parent = ws.parent().unwrap(); + assert!( + !parent.join("escape.md").exists(), + ".. traversal must be blocked" + ); + std::fs::remove_dir_all(&ws).ok(); + } } From 2ad5a0cbaaf8db8cce0a2b466c811722e8252459 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 08:38:15 -0700 Subject: [PATCH 58/76] kres: add --one to exit after the work-stop condition fires On a real TTY, kres stays open after \`--turns N\` exhausts or \`--turns 0\` hits goal-met / no-progress / no-goal-batch-finished, waiting for the next operator prompt. Batch-style invocations want the process to end at that point without dropping into the redirected-stdout escape hatch. Add a \`--one\` bool flag (default off) that ORs into the existing exit_on_idle gate alongside the \`!stdout.is_terminal()\` check, so the reaper takes the same exit + auto-summary path either way. Signed-off-by: Chris Mason --- kres/src/main.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/kres/src/main.rs b/kres/src/main.rs index 77b2f66..6eed200 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -101,6 +101,15 @@ struct ReplArgs { /// cap still wins there. #[arg(long, default_value_t = false)] follow: bool, + /// Exit the REPL once the work-stop condition fires, instead of + /// staying open waiting for further operator input. Same exit + /// path as the existing piped-stdout case (auto-renders summary + /// before teardown). Useful for batch-style invocations on a + /// real TTY where the operator wants the kres process to end as + /// soon as `--turns N` exhausts or `--turns 0` hits goal-met / + /// no-progress / no-goal-batch-finished. + #[arg(long, default_value_t = false)] + one: bool, /// Resume from a prior `session.json` in the results dir. /// When false (default), kres ignores any existing session.json /// and starts clean — even when `--results DIR` points at a @@ -765,7 +774,10 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // so once the work-stop condition fires there is no one to // type the next prompt. Match the existing `--turns N` exit // path and quit the REPL when stdout isn't a tty. - exit_on_idle: !std::io::IsTerminal::is_terminal(&std::io::stdout()), + // `--one` forces the same exit path the piped-stdout case + // takes; otherwise default to "exit when stdout has no + // terminal on the other end". + exit_on_idle: args.one || !std::io::IsTerminal::is_terminal(&std::io::stdout()), }; let mut session = Session::new(mgr, cfg).await; // Resume from a prior session.json ONLY when `--resume` was @@ -1270,6 +1282,14 @@ mod tests { assert_eq!(c.repl.turns, 3); } + #[test] + fn one_flag_defaults_off_and_parses() { + let bare = Cli::try_parse_from(["kres"]).unwrap(); + assert!(!bare.repl.one, "--one must default off"); + let with = Cli::try_parse_from(["kres", "--one"]).unwrap(); + assert!(with.repl.one, "--one must parse as a bare bool flag"); + } + #[test] fn slow_tag_unset_when_not_passed() { // --slow is now Option with no clap default, so the From 059ef7d311185c91b47443b794cf62a99a30e26d Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 08:24:49 -0700 Subject: [PATCH 59/76] kres: embed triage template and stop granting consent on bare / MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues showed up while wiring a triage prompt to write summary.md back into the per-finding export folder. First, the template lived as an operator-installed file at \`~/.kres/prompts/triage-template.md\`, which made distribution and discoverability worse than the existing \`review\`/\`summary\` slash commands. Second, prose in the template like "\`relevant_symbols\` / \`relevant_file_sections\`" tokenised under \`split_whitespace\` into a bare \`/\`, which \`grant_paths_from_text\` resolved to filesystem root and added to the consent store — defeating the consent gate that \`persist_code_output\` and \`edit_file\` rely on, since \`is_allowed\` then returned true for every absolute path. Embed \`triage-template.md\` as a fourth slash-command via \`user_commands::TABLE\`, mirroring \`review\`/\`summary\`/\`summary-markdown\` — overridable by dropping a file at \`~/.kres/commands/triage.md\`. Skip the file in \`setup.sh\` so the legacy \`~/.kres/prompts/\` location can no longer shadow it. In \`consent::looks_like_path\`, reject bare \`/\`, \`.\`, and \`..\` tokens that come from prose conjunctions ("\`yes\` / \`no\`", "foo / bar"); \`grant_from_mention\` separately refuses any path whose parent is None as belt-and-braces for future callers. Replace the two bare-\`/\` separators in the template prose so the embedded copy can never trigger this even if a future caller bypasses the new guard. Signed-off-by: Chris Mason --- configs/prompts/triage-template.md | 109 +++++++++++++++++++++++++++++ kres-agents/src/user_commands.rs | 22 +++++- kres-core/src/consent.rs | 43 ++++++++++++ setup.sh | 2 +- 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 configs/prompts/triage-template.md diff --git a/configs/prompts/triage-template.md b/configs/prompts/triage-template.md new file mode 100644 index 0000000..f9425a2 --- /dev/null +++ b/configs/prompts/triage-template.md @@ -0,0 +1,109 @@ +You are triaging a single kernel bug finding produced by `kres --export`. + +## Input directory + +The **first line of this prompt is the absolute path of the finding's +directory** — call it `DIR`. Use that exact path everywhere below; +do not invent a different one and do not treat `$DIR` as a shell +variable that something else expands. + +`DIR` contains: + +- `DIR/metadata.yaml` — id, title, severity, status, filename, + subsystem (may be empty), git head, optional `introduced_by`, and + lists of `relevant_symbols` and `relevant_file_sections`. +- `DIR/FINDING.md` — full narrative: summary, mechanism, reproducer, + impact, fix sketch, open questions, per-task analysis details, + relevant symbols and file excerpts. + +Read both before writing. Do not invent facts that aren't in those +two files or in the actual source tree at `metadata.yaml`'s +`git.sha`. + +## Output + +Write the triage to `DIR/summary.md`, replacing any existing copy. + +Emit it as a single `code_output` entry with `path` set to the +**absolute** `DIR/summary.md` path. The operator named `DIR` in the +prompt, so the consent gate already permits writes there — no bash, +no cp, no relative-path hack: + +``` +"code_output": [ + { + "path": "/summary.md", + "content": "", + "purpose": "triage summary" + } +] +``` + +## Format + +Unless you're quoting code, lines MUST be wrapped at 78 characters. Long +lines are not allowed, count characters as you write. + +Use exactly the section headings below, in this order. Every section +is required. Keep prose tight — short triage doc, not a re-run of +FINDING.md. + +``` +# Subject: + +# Status + + + +# Subsystem + + + +# Impact + + + +# Requirements + + + +- Host access? +- Remote access? +- Only under specific CONFIG_? +- As root? +- As regular user? + + + +# Details + + +``` + +## Rules + +- The Subject line is the `# Subject:` heading itself — don't add a + separate first heading above it. +- Status values are exactly one of `Fixed`, `Plausible`, `Unknown`, + `Invalid`. Match the metadata's `status:` when it's `invalidated` + (→ `Invalid`); otherwise pick the best fit from the FINDING.md + evidence. Use `Unknown` when you can't tell, not a guess. +- Subsystem is one sentence. Name the kernel area (e.g. "btrfs + extent allocator", "TCP input path", "mac80211 rx") plus the file + and function. Pull the file from `metadata.yaml`'s `filename:` + when present. +- Impact prose stays in plain English. No "may", "could", "should" + hedging unless FINDING.md actually says so — and if it does, cite + it. Don't speculate beyond what the finding documents. +- Requirements: answer each question with one of `yes`, `no`, or + `n/a` before the explanatory paragraph. If FINDING.md doesn't say, + write `unknown` — don't guess. +- Details is a synopsis, not a re-paste of FINDING.md. Three to six + sentences is plenty. +- Do not edit FINDING.md or metadata.yaml. Only write summary.md. diff --git a/kres-agents/src/user_commands.rs b/kres-agents/src/user_commands.rs index 564f597..4ff9f83 100644 --- a/kres-agents/src/user_commands.rs +++ b/kres-agents/src/user_commands.rs @@ -36,6 +36,10 @@ const TABLE: &[(&str, &str)] = &[ "summary-markdown", include_str!("../../configs/prompts/bug-summary-markdown.md"), ), + ( + "triage", + include_str!("../../configs/prompts/triage-template.md"), + ), ]; /// Return the body for `name` — disk override wins, then the @@ -128,7 +132,7 @@ mod tests { #[test] fn all_expected_commands_are_present() { - for expected in ["review", "summary", "summary-markdown"] { + for expected in ["review", "summary", "summary-markdown", "triage"] { assert!( lookup(expected).is_some(), "expected embedded command {expected} not found" @@ -136,6 +140,22 @@ mod tests { } } + #[test] + fn triage_body_contains_template_markers() { + // Sanity check — same shape as the review marker test. + // If the include_str stops pointing at triage-template.md + // this catches it. + let body = lookup("triage").unwrap(); + assert!( + body.contains("# Subject:"), + "triage body missing `# Subject:` heading" + ); + assert!( + body.contains("triage summary"), + "triage body missing the code_output `purpose` example" + ); + } + #[test] fn unknown_name_returns_none() { assert!(lookup("no-such-command").is_none()); diff --git a/kres-core/src/consent.rs b/kres-core/src/consent.rs index 8e2c57b..4d4e6ea 100644 --- a/kres-core/src/consent.rs +++ b/kres-core/src/consent.rs @@ -55,6 +55,16 @@ impl ConsentStore { } else { return None; }; + // Granting the filesystem root would make is_allowed() true + // for every absolute path on the host, which defeats the + // entire consent gate. A bare `/` token is never a meaningful + // operator mention; refuse to record it. (`looks_like_path` + // also filters this token, but leaves the rule here as + // belt-and-braces for any future caller that resolves a path + // outside grant_paths_from_text.) + if dir.parent().is_none() { + return None; + } let mut g = self.granted.write().unwrap(); g.insert(dir.clone()); Some(dir) @@ -189,6 +199,15 @@ fn looks_like_path(s: &str) -> bool { if s.is_empty() { return false; } + // Bare separators that operator prose uses as conjunctions — + // `foo / bar`, `yes / no` — split into a lone "/" token. Never + // a real path mention; resolves to filesystem root if we let it + // through, which would consent-grant the entire FS. Same logic + // for the lone parent / current-dir tokens which can sneak in + // when prompts use `..` or `.` as ellipsis. + if matches!(s, "/" | "." | "..") { + return false; + } // Skip URL-scheme tokens — `https://github.com/x`, `s3://bucket/key`, // `git+ssh://host/repo`. They contain `/` and would otherwise // burn a stat() syscall in resolve_candidate. Scheme syntax @@ -349,6 +368,30 @@ mod tests { assert!(added.is_empty()); } + #[test] + fn text_scanner_ignores_bare_slash_separator() { + // Regression: a prompt with prose like "lists of `foo` / + // `bar`" used to produce a "/" token from split_whitespace, + // which resolved to filesystem root and granted `/` — making + // is_allowed() true for every absolute path on the host. + let s = ConsentStore::new(); + let added = grant_paths_from_text( + &s, + Path::new("/tmp"), + "lists of `relevant_symbols` / `relevant_file_sections` and yes / no / n/a", + ); + assert!(added.is_empty(), "bare separators must not grant: {added:?}"); + assert!(!s.is_allowed(Path::new("/etc/passwd"))); + } + + #[test] + fn grant_from_mention_refuses_filesystem_root() { + let s = ConsentStore::new(); + let got = s.grant_from_mention(Path::new("/")); + assert!(got.is_none(), "granting / would defeat the consent gate"); + assert!(!s.is_allowed(Path::new("/etc/passwd"))); + } + #[test] fn is_suspicious_grant_flags_top_level_system_dirs() { for d in ["/usr", "/etc", "/var", "/opt", "/bin", "/lib", "/home"] { diff --git a/setup.sh b/setup.sh index 51e9b32..ca10453 100755 --- a/setup.sh +++ b/setup.sh @@ -247,7 +247,7 @@ mkdir -p "${DEST}/prompts" shopt -s nullglob for src in "${CONFIGS_SRC}/prompts"/*.md; do case "$(basename "$src")" in - *.system.md | bug-summary.md | bug-summary-markdown.md | review-template.md) + *.system.md | bug-summary.md | bug-summary-markdown.md | review-template.md | triage-template.md) # Embedded in the binary; skip. ;; *) From bbf5d1787243e6c0b248f35957bd6c7d35d20965 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 08:51:03 -0700 Subject: [PATCH 60/76] summary: switch the embedded bug-summary template to plain statements An earlier in-flight edit converted the top of bug-summary.md from question phrasing ("Does this code corrupt memory?") to plain statements ("This code corrupts memory."), but the rest of the file still demanded question form: the hard-rule banner, the ask-short-questions paragraph, the AVOID/USE INSTEAD examples, the structure section's step 5/6, and the worked sample at the bottom all kept the prior style, so a model following the template would still emit questions and contradict the new framing. Finish the conversion through the rest of the file: rename the banner enumeration, drop "ask short questions" in favour of stating the bug plainly, rewrite the widget_claim/widget_destroy worked examples in declarative form, replace "a series of statements followed by a question" with the punchline framing the rule was reaching for, and swap step 5's "phrased as a question where possible" for a flat "concise plain statement of the bug" with step 6 updated to match. Signed-off-by: Chris Mason --- configs/prompts/bug-summary.md | 52 +++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/configs/prompts/bug-summary.md b/configs/prompts/bug-summary.md index f6dfe52..8aa4668 100644 --- a/configs/prompts/bug-summary.md +++ b/configs/prompts/bug-summary.md @@ -6,7 +6,7 @@ THAN 72, INSERT A NEWLINE AND WORD-WRAP BEFORE EMITTING. THIS IS A HARD LIMIT, NOT A SUGGESTION. THE ONLY LINES ALLOWED TO EXCEED 72 CHARACTERS ARE VERBATIM CODE FRAGMENTS QUOTED FROM SOURCE (function prototypes, struct definitions, identifiers where breaking would change meaning). -EVERY PROSE LINE — FRAMING, SUBJECT:, QUESTIONS, CALL CHAINS, +EVERY PROSE LINE — FRAMING, SUBJECT:, STATEMENTS, CALL CHAINS, OBSERVATIONS — WRAPS AT 72. IF A Subject: LINE WOULD EXCEED 72 CHARACTERS, TIGHTEN THE WORDING UNTIL IT FITS; NEVER BREAK A Subject: LINE ACROSS TWO LINES. @@ -22,7 +22,7 @@ analysis task that contributed to a finding. Your job is to turn those inputs into a single, plain-text bug report covering every bug that was found. Treat the task_observations text as supporting detail to fold into the relevant bug's section — quote from it when -it sharpens the question, do not attribute observations to tasks. +it sharpens the statement, do not attribute observations to tasks. - If original_prompt is non-empty, open the report with one or two sentences of plain-text context that restates what the run was looking @@ -48,23 +48,24 @@ absolutely and completely plain text fit for the linux kernel mailing list. - The report must be conversational with undramatic wording, fit for sending as a bug report to the linux kernel mailing list. - Report must be factual. just technical observations. - - Report should be framed as questions, not accusations. + - Report should be framed as plain statements, not accusations. - Call issues "bugs", never use the word critical. - NEVER EVER USE ALL CAPS. -- Explain the bugs as questions about the code, but do not mention +- Explain the bugs as statements about the code, but do not mention any specific author. - don't say: Did you corrupt memory here? - - instead say: Can this corrupt memory? or Does this code ... + - instead say: This code corrupts memory ... -- Vary your question phrasing. Don't start with "Does this code ..." every +- Vary your statement phrasing. Don't start with "This code ..." every time. -- Ask your question specifically about the sources you're referencing: - - If the bug is a leak, don't call it a 'resource leak', ask specifically - about the resource you think is leaking. 'Does this code leak the folio?' - - Don't say: 'Does this loop have a bounds checking issue?' Name the - variable you think is overflowing: "Does this code overflow xyz[]?" +- Make your statement specifically about the sources you're referencing: + - If the bug is a leak, don't call it a 'resource leak', name + specifically the resource you think is leaking. 'This code leaks + the folio.' + - Don't say: 'This loop has a bounds checking issue.' Name the + variable you think is overflowing: 'This code overflows xyz[].' - Do not add explanatory content about why something matters or what benefits a fix would provide. State the issue and the suggestion, nothing @@ -94,24 +95,24 @@ detail you want to cite, drop that detail rather than guess. ## Ensure clear, concise paragraphs -Never make long or dense confusing paragraphs, ask short questions backed -up by code snippets (in plain text), or call chains if needed. +Never make long or dense confusing paragraphs. State the bug plainly, +backed up by code snippets (in plain text), or call chains if needed. The examples below use a fictional `drivers/example/widget.c` so the format is clear without tying the sample to any real bug. ### AVOID ``` -Can this sequence actually occur? Looking at widget_claim() in +This sequence can occur. Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called widget_release() which -sets w->owner = NULL, wouldn't CPU2 check owner, see it is NULL, take +sets w->owner = NULL, CPU2 checks owner, sees it is NULL, and takes the 'already released' path with mutex_unlock/put_widget/goto retry -instead of calling widget_release() again? +instead of calling widget_release() again. ``` ### USE INSTEAD ``` -Can this sequence actually occur? Looking at widget_claim() in +This sequence can occur. Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called widget_release() and set w->owner = NULL: @@ -119,7 +120,7 @@ CPU1 widget_release() w->owner = NULL; -CPU2 would see this in widget_claim(): +CPU2 then sees this in widget_claim(): if (!w->owner) { pr_debug("widget %p already released\n", w); mutex_unlock(&w->lock); @@ -128,7 +129,7 @@ CPU2 would see this in widget_claim(): goto retry; } -and take the goto retry path instead of calling widget_release() again? +and takes the goto retry path instead of calling widget_release() again. ``` Dense paragraphs are hard to read. Spread the information out so @@ -137,8 +138,8 @@ it is easier to follow. If you have a series of factual sentences, break them up into logical groups with a blank line between each group. -If you have a series of statements followed by a question, put a blank -line before the question. +If a paragraph builds up to the punchline (the actual bug claim), put +a blank line before the punchline. ## NEVER EVER ALL CAPS @@ -246,9 +247,8 @@ Each section must cover, in order: wrap a `bug-impact:` line. If the sentence would not fit, tighten the wording. 4. A blank line. -5. A concise question or statement of the bug, phrased as a question where - possible. -6. Any code snippets needed to make the question concrete. Use the same +5. A concise plain statement of the bug. +6. Any code snippets needed to make the statement concrete. Use the same snippet style shown above: filename:function() { ... } with the smallest excerpt that makes the point. Plain indentation, no ``` fences. 7. The call chain, if relevant. Write it inline as funcA() -> funcB() -> @@ -275,7 +275,7 @@ Subject: widget_destroy acquires slab_lock and ref_lock in the wrong order bug-severity: high bug-impact: deadlock between widget teardown and reinit on SMP systems -Can this sequence deadlock against a concurrent widget_reinit()? In +This sequence deadlocks against a concurrent widget_reinit(). In drivers/example/widget.c:widget_destroy(), the cleanup path takes the locks in this order: @@ -299,5 +299,5 @@ drivers/example/widget.c:widget_reinit() { Call chain reaching the bad ordering: module_exit() -> widget_teardown() -> widget_destroy(). -Does lockdep complain about this when CONFIG_PROVE_LOCKING is enabled? +lockdep flags this when CONFIG_PROVE_LOCKING is enabled. ``` From 8995f02fa055cc8ad046b9c45e4c15ce3d982330 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 08:53:32 -0700 Subject: [PATCH 61/76] summary: switch the markdown bug-summary template to plain statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plain-text bug-summary template moved from question phrasing to flat statements (commit f1e6653); the markdown variant still demanded question form in the same spots — banner enumeration, ask-short-questions paragraph, AVOID/USE INSTEAD examples, structure step 5/6, and the worked sample at the bottom. Without this follow-up, --summary-markdown would emit questions while --summary emits statements. Apply the same conversion to bug-summary-markdown.md, preserving the markdown-only bits (\`backtick\`-wrapped identifiers, \`\`\`c fenced code blocks). The user-preserved "don't say: Did you corrupt memory here?" negative example stays as the one remaining \`?\`. Signed-off-by: Chris Mason --- configs/prompts/bug-summary-markdown.md | 50 ++++++++++++------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/configs/prompts/bug-summary-markdown.md b/configs/prompts/bug-summary-markdown.md index ba72d2b..6d23a47 100644 --- a/configs/prompts/bug-summary-markdown.md +++ b/configs/prompts/bug-summary-markdown.md @@ -6,7 +6,7 @@ THAN 72, INSERT A NEWLINE AND WORD-WRAP BEFORE EMITTING. THIS IS A HARD LIMIT, NOT A SUGGESTION. THE ONLY LINES ALLOWED TO EXCEED 72 CHARACTERS ARE VERBATIM CODE FRAGMENTS QUOTED FROM SOURCE (function prototypes, struct definitions, identifiers where breaking would change meaning). -EVERY PROSE LINE — FRAMING, SUBJECT:, QUESTIONS, CALL CHAINS, +EVERY PROSE LINE — FRAMING, SUBJECT:, STATEMENTS, CALL CHAINS, OBSERVATIONS — WRAPS AT 72. IF A Subject: LINE WOULD EXCEED 72 CHARACTERS, TIGHTEN THE WORDING UNTIL IT FITS; NEVER BREAK A Subject: LINE ACROSS TWO LINES. @@ -22,7 +22,7 @@ analysis task that contributed to a finding. Your job is to turn those inputs into a single markdown bug report covering every bug that was found. Treat the task_observations text as supporting detail to fold into the relevant bug's section — quote from it when -it sharpens the question, do not attribute observations to tasks. +it sharpens the statement, do not attribute observations to tasks. - If original_prompt is non-empty, open the report with one or two sentences of context that restates what the run was looking into, @@ -53,21 +53,20 @@ technical bug report. - Call issues "bugs", never use the word critical. - NEVER EVER USE ALL CAPS. -- Explain the bugs as questions about the code, but do not mention +- Explain the bugs as statements about the code, but do not mention any specific author. - don't say: Did you corrupt memory here? - - instead say: Can this corrupt memory? or Does this code ... + - instead say: This code corrupts memory ... -- Vary your question phrasing. Don't start with "Does this code ..." +- Vary your statement phrasing. Don't start with "This code ..." every time. - Make statements specifically about the sources you're referencing: - - If the bug is a leak, don't call it a 'resource leak', ask - specifically about the resource you think is leaking. 'Does this - code leak the folio?' - - Don't say: 'Does this loop have a bounds checking issue?' Name the - variable you think is overflowing: "Does this code overflow - xyz[]?" + - If the bug is a leak, don't call it a 'resource leak', name + specifically the resource you think is leaking. 'This code leaks + the folio.' + - Don't say: 'This loop has a bounds checking issue.' Name the + variable you think is overflowing: 'This code overflows xyz[].' - Do not add explanatory content about why something matters or what benefits a fix would provide. State the issue and the suggestion, @@ -97,7 +96,7 @@ detail you want to cite, drop that detail rather than guess. ## Ensure clear, concise paragraphs -Never make long or dense confusing paragraphs, ask short questions +Never make long or dense confusing paragraphs. State the bug plainly, backed up by code snippets, or call chains if needed. The examples below use a fictional `drivers/example/widget.c` so the @@ -105,16 +104,16 @@ format is clear without tying the sample to any real bug. ### AVOID ``` -Can this sequence actually occur? Looking at widget_claim() in +This sequence can occur. Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called widget_release() which -sets w->owner = NULL, wouldn't CPU2 check owner, see it is NULL, take +sets w->owner = NULL, CPU2 checks owner, sees it is NULL, and takes the 'already released' path with mutex_unlock/put_widget/goto retry -instead of calling widget_release() again? +instead of calling widget_release() again. ``` ### USE INSTEAD ``` -Can this sequence actually occur? Looking at `widget_claim()` in +This sequence can occur. Looking at `widget_claim()` in `drivers/example/widget.c`, if CPU1 already called `widget_release()` and set `w->owner = NULL`: @@ -122,7 +121,7 @@ CPU1 widget_release() w->owner = NULL; -CPU2 would see this in `widget_claim()`: +CPU2 then sees this in `widget_claim()`: ```c if (!w->owner) { @@ -134,8 +133,8 @@ if (!w->owner) { } ``` -and take the `goto retry` path instead of calling `widget_release()` -again? +and takes the `goto retry` path instead of calling `widget_release()` +again. ``` Dense paragraphs are hard to read. Spread the information out so @@ -144,8 +143,8 @@ it is easier to follow. If you have a series of factual sentences, break them up into logical groups with a blank line between each group. -If you have a series of statements followed by a question, put a blank -line before the question. +If a paragraph builds up to the punchline (the actual bug claim), put +a blank line before the punchline. ## NEVER EVER ALL CAPS @@ -260,9 +259,8 @@ including the Subject: wrap a `bug-impact:` line. If the sentence would not fit, tighten the wording. 4. A blank line. -5. A concise question or statement of the bug, phrased as a question - where possible. -6. Any code snippets needed to make the question concrete. Wrap +5. A concise plain statement of the bug. +6. Any code snippets needed to make the statement concrete. Wrap C snippets in ```c fenced code blocks; use inline backticks for short identifiers within prose. 7. The call chain, if relevant. Write it inline as `funcA() -> @@ -292,7 +290,7 @@ fictional and exists only to show the shape. bug-severity: high bug-impact: deadlock between widget teardown and reinit on SMP systems -Can this sequence deadlock against a concurrent `widget_reinit()`? In +This sequence deadlocks against a concurrent `widget_reinit()`. In `drivers/example/widget.c:widget_destroy()`, the cleanup path takes the locks in this order: @@ -314,5 +312,5 @@ raw_spin_lock(&w->ref_lock); Call chain reaching the bad ordering: `module_exit()` -> `widget_teardown()` -> `widget_destroy()`. -Does lockdep complain about this when CONFIG_PROVE_LOCKING is enabled? +lockdep flags this when CONFIG_PROVE_LOCKING is enabled. ``` From 7d152746825d370b610b447c0095b912fefcee52 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 08:59:35 -0700 Subject: [PATCH 62/76] templates: cleanup language and formatting Signed-off-by: Chris Mason --- configs/prompts/bug-summary-markdown.md | 7 ++-- configs/prompts/bug-summary.md | 14 ++++---- configs/prompts/triage-template.md | 46 ++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/configs/prompts/bug-summary-markdown.md b/configs/prompts/bug-summary-markdown.md index 6d23a47..6d174e2 100644 --- a/configs/prompts/bug-summary-markdown.md +++ b/configs/prompts/bug-summary-markdown.md @@ -104,7 +104,7 @@ format is clear without tying the sample to any real bug. ### AVOID ``` -This sequence can occur. Looking at widget_claim() in +Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called widget_release() which sets w->owner = NULL, CPU2 checks owner, sees it is NULL, and takes the 'already released' path with mutex_unlock/put_widget/goto retry @@ -113,9 +113,8 @@ instead of calling widget_release() again. ### USE INSTEAD ``` -This sequence can occur. Looking at `widget_claim()` in -`drivers/example/widget.c`, if CPU1 already called `widget_release()` -and set `w->owner = NULL`: +Looking at `widget_claim()` in `drivers/example/widget.c`, +if CPU1 already called `widget_release()` and set `w->owner = NULL`: CPU1 widget_release() diff --git a/configs/prompts/bug-summary.md b/configs/prompts/bug-summary.md index 8aa4668..0258d1d 100644 --- a/configs/prompts/bug-summary.md +++ b/configs/prompts/bug-summary.md @@ -103,18 +103,16 @@ format is clear without tying the sample to any real bug. ### AVOID ``` -This sequence can occur. Looking at widget_claim() in -drivers/example/widget.c, if CPU1 already called widget_release() which -sets w->owner = NULL, CPU2 checks owner, sees it is NULL, and takes -the 'already released' path with mutex_unlock/put_widget/goto retry -instead of calling widget_release() again. +Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called +widget_release() which sets w->owner = NULL, CPU2 checks owner, sees it is +NULL, and takes the 'already released' path with mutex_unlock/put_widget/goto +retry instead of calling widget_release() again. ``` ### USE INSTEAD ``` -This sequence can occur. Looking at widget_claim() in -drivers/example/widget.c, if CPU1 already called widget_release() and set -w->owner = NULL: +Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called +widget_release() and set w->owner = NULL: CPU1 widget_release() diff --git a/configs/prompts/triage-template.md b/configs/prompts/triage-template.md index f9425a2..78183b9 100644 --- a/configs/prompts/triage-template.md +++ b/configs/prompts/triage-template.md @@ -86,6 +86,49 @@ this summary.md is what gets read while triaging, so keep it skimmable.> ``` +## Wording choices + +- Dense paragraphs are hard to read. Spread the information out so +it is easier to follow. + - If you have a series of factual sentences, break them up into logical +groups with a blank line between each group. + - If you have a series of statements followed by a question, put a blank +line before the question. + +### AVOID +``` +Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called +widget_release() which sets w->owner = NULL, CPU2 checks owner, sees it is +NULL, and takes the 'already released' path with mutex_unlock/put_widget/goto +retry instead of calling widget_release() again. +``` + +### USE INSTEAD +``` +Looking at widget_claim() in drivers/example/widget.c, if CPU1 already called +widget_release() and set w->owner = NULL: + +CPU1 +widget_release() + w->owner = NULL; + +CPU2 then sees this in widget_claim(): + if (!w->owner) { + pr_debug("widget %p already released\n", w); + mutex_unlock(&w->lock); + put_widget(w); + ... + goto retry; + } + +and takes the goto retry path instead of calling widget_release() again. +``` + +## metadata.yml update +- `metadata.yml` contains a subsystem field that may not be filled in. If you've +determined which subsystem this bug belongs to, fill in that subsystem field. +- THIS IS THE ONLY EDIT YOU'RE ALLOWED TO MAKE IN `metadata.yml` + ## Rules - The Subject line is the `# Subject:` heading itself — don't add a @@ -106,4 +149,5 @@ skimmable.> write `unknown` — don't guess. - Details is a synopsis, not a re-paste of FINDING.md. Three to six sentences is plenty. -- Do not edit FINDING.md or metadata.yaml. Only write summary.md. +- Do not edit FINDING.md. Only write summary.md. + From c5ff132ffabbee0710950170f818f875494aa76b Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 09:33:50 -0700 Subject: [PATCH 63/76] kres-repl: drop the auto /summary at REPL exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a clean \`--turns N\` run the REPL was rendering summary.txt right before teardown via cmd_summary, on top of the deferred-list banner and the "exiting REPL" line. The render is unwanted noise during batch invocations: the operator runs /summary themselves before quitting, or \`kres --summary\` against the results dir afterwards. Remove the post-loop auto-summary block. The companion \`turns_exhausted\` and \`any_coding_task\` AtomicBool fields had no other readers, so drop the field definitions, the two constructor inits, the reaper-side store sites, the now-stale comments mentioning the gate, and the submit_prompt latch that fed any_coding_task. The exit_on_idle goal-met branch keeps cancelling root_shutdown — same exit, just no auto-render. Signed-off-by: Chris Mason --- kres-repl/src/session.rs | 75 +++++----------------------------------- 1 file changed, 9 insertions(+), 66 deletions(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index 320d579..c85d1ba 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -238,16 +238,6 @@ pub struct Session { /// Lets the reaper tick skip no-op fsyncs when nothing changed. /// Zero means "never persisted" and always triggers a write. persist_sig: Arc, - /// Set to true by the reaper when the --turns cap is reached. - /// The main REPL loop checks this after root_shutdown breaks the - /// select; when true, /summary is invoked before teardown so the - /// operator gets a summary.txt on a clean --turns N run. - turns_exhausted: Arc, - /// True once any task has run in Coding mode during this session. - /// Suppresses the teardown summary — coding-mode sessions don't - /// have findings to summarise and the summary template would - /// produce gibberish. - any_coding_task: Arc, /// Set by `/stop`; cleared by `submit_prompt` and `/continue`. /// While set, the idle-loop auto-continue does not fire. Without /// this latch `/stop` only cancels the currently-running tasks, @@ -434,8 +424,6 @@ impl Session { interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), last_prompt: Arc::new(tokio::sync::Mutex::new(None)), persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), - turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), - any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_notify: Arc::new(tokio::sync::Notify::new()), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -464,8 +452,6 @@ impl Session { interrupted_prompt: Arc::new(tokio::sync::Mutex::new(None)), last_prompt: Arc::new(tokio::sync::Mutex::new(None)), persist_sig: Arc::new(std::sync::atomic::AtomicU64::new(0)), - turns_exhausted: Arc::new(std::sync::atomic::AtomicBool::new(false)), - any_coding_task: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_latched: Arc::new(std::sync::atomic::AtomicBool::new(false)), stop_notify: Arc::new(tokio::sync::Notify::new()), status_paused: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -865,7 +851,6 @@ impl Session { // instead of burying the file in // ~/.kres/sessions//code/hello-world.c. let code_output_root_for_reaper: PathBuf = self.cfg.workspace.clone(); - let turns_exhausted_for_reaper = self.turns_exhausted.clone(); let stop_latched_for_reaper = self.stop_latched.clone(); let stop_notify_for_reaper = self.stop_notify.clone(); let turns_limit = self.cfg.turns_limit; @@ -1584,12 +1569,6 @@ impl Session { "[{carry} pending item(s) deferred — see /followup]" ); } - // Flag set BEFORE cancel so the main loop, - // which breaks on root_shutdown.cancelled(), - // sees the flag already asserted when it - // reaches the post-loop /summary gate. - turns_exhausted_for_reaper - .store(true, std::sync::atomic::Ordering::Release); kres_core::async_eprintln!("exiting REPL."); mgr_for_reaper.root_shutdown().cancel(); break; @@ -1690,9 +1669,9 @@ impl Session { // /followup's deferred list. Done/Skipped // items stay so the plan step rollup can // still see them. Unlike the --turns N path - // we do NOT cancel the root shutdown or flag - // turns_exhausted — the user wants to keep - // driving the REPL after goal met. + // we do NOT cancel the root shutdown — the + // user wants to keep driving the REPL after + // goal met. let drained = mgr_for_reaper.drain_pending_blocked().await; let carry = drained.len(); let mut deferred = deferred_for_reaper.lock().await; @@ -1704,14 +1683,11 @@ impl Session { ); } turns0_stop_announced = true; - // Non-tty stdout: exit on first stop, same as - // `--turns N`. Set turns_exhausted so the - // post-loop summary auto-renders, then cancel + // Non-tty stdout (or --one): exit on first + // stop, same as `--turns N`. Cancel // root_shutdown to break the REPL select on // root_shutdown.cancelled(). if exit_on_idle { - turns_exhausted_for_reaper - .store(true, std::sync::atomic::Ordering::Release); mgr_for_reaper.root_shutdown().cancel(); break; } @@ -1908,34 +1884,10 @@ impl Session { } } - // --turns exit path: reaper flips turns_exhausted when the - // slow-agent run count hits cfg.turns_limit, then cancels - // root_shutdown to break the REPL loop above. On a clean - // --turns run, render a summary via /summary before - // teardown so the operator gets the artifact without having - // to run `kres --summary` afterwards. - // - // Suppress the auto-summary when the session ran any coding - // task: coding sessions don't produce findings, and the - // bug-summary template would emit gibberish against the - // coding notes in report.md. - let coding_session = self - .any_coding_task - .load(std::sync::atomic::Ordering::Acquire); - if self - .turns_exhausted - .load(std::sync::atomic::Ordering::Acquire) - { - if coding_session { - kres_core::async_eprintln!( - "--turns: skipping summary (coding session — see / for emitted files)" - ); - } else { - kres_core::async_eprintln!("--turns: rendering summary.txt before exit"); - self.cmd_summary(None, false).await; - } - } - + // --turns exit path drops straight into teardown — no + // auto-summary. Operators who want the artifact run /summary + // before quitting, or `kres --summary` against the results + // dir afterwards. let out = self.mgr.stop_all(self.cfg.stop_grace).await; if out.requested > 0 { kres_core::async_eprintln!( @@ -2117,15 +2069,6 @@ impl Session { } else { (None, kres_agents::TaskMode::default()) }; - // Latch the session-wide "coding session" flag as soon as any - // task is submitted in coding mode. The teardown path reads - // this to suppress the teardown /summary — a coding session - // has no findings to summarise, and running the bug-summary - // template over coding notes produces nonsense. - if matches!(task_mode, kres_agents::TaskMode::Coding) { - self.any_coding_task - .store(true, std::sync::atomic::Ordering::Release); - } // Ask the goal agent for a plan decomposition, but only on // operator-typed submissions — pipeline-driven follow-ups // already live under the original plan and should not spawn From bd090cd4803fe1df4132c4bad0be9fee0a662d22 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 11:27:21 -0700 Subject: [PATCH 64/76] kres: append pid to the default ~/.kres/sessions/ tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default session-id was \`chrono::Utc::now().format("%Y%m%dT%H%M%SZ")\` — seconds resolution. A bulk-launch wrapper (the in-progress triage-all.py runs 20 kres processes at a time) had every parallel process collide on the same timestamp, multiple kres instances all sharing the same ~/.kres/sessions// dir, which then races on session.json / findings.json / report.md / prompt.md and crashes the Rust side with exit 101. Append \`-\` so the session id becomes e.g. 20260425T180000Z-12345. Two concurrent kres processes get distinct dirs; the timestamp prefix still keeps \`ls -lt ~/.kres/sessions/\` ordered. Signed-off-by: Chris Mason --- kres/src/main.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/kres/src/main.rs b/kres/src/main.rs index 6eed200..3e8b093 100644 --- a/kres/src/main.rs +++ b/kres/src/main.rs @@ -537,7 +537,11 @@ async fn run_repl(args: ReplArgs) -> Result<()> { // `--results DIR` sets the default dir for findings/report/todo. // Individual `--findings FILE`, `--report FILE`, `--todo FILE` // override their own slot. When --results is absent, the default - // is ~/.kres/sessions// (session-id is a timestamp). + // is ~/.kres/sessions//. The session-id is a UTC + // timestamp + pid: bulk-launching parallel kres processes (e.g. + // a triage-all wrapper) used to collide on the timestamp alone + // because chrono's seconds-resolution string was identical for + // every process started in the same second. // Treat --summary and --summary-markdown as the same "standalone // summary" entry; the markdown flag just picks the variant // template and filename further down. @@ -555,7 +559,8 @@ async fn run_repl(args: ReplArgs) -> Result<()> { (None, true) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), (None, false) => { let base = kres_dir().unwrap_or_else(|| PathBuf::from(".")); - let session_id = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let session_id = format!("{ts}-{}", std::process::id()); base.join("sessions").join(session_id) } }; From 8444836a6895a68dbd7b54355ced588c3f24b7ab Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 09:49:26 -0700 Subject: [PATCH 65/76] kres-repl: add a Subsystem column to the --export INDEX.md table INDEX.md showed Severity / Date / Status / ID / Title; the new triage flow fills in metadata.yaml's subsystem field per finding, but the index table didn't surface it, so an operator scanning INDEX.md still had to drill into each FINDING.md to see where the bug lived. Adding the column lets the same overview answer "which subsystems do my findings cluster in?" without leaving the index page. Read `subsystem:` via the existing top_level_scalar helper, store it as Option on IndexRow with empty values folded to None, and render it between Severity and Date so the bug-attribute facets sit together. Empty / absent values render as the em-dash placeholder already used for missing dates. Signed-off-by: Chris Mason --- kres-repl/src/export.rs | 395 ++++++++++++++--------------- scripts/findings-index.py | 519 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 707 insertions(+), 207 deletions(-) create mode 100755 scripts/findings-index.py diff --git a/kres-repl/src/export.rs b/kres-repl/src/export.rs index e381d36..f620c6b 100644 --- a/kres-repl/src/export.rs +++ b/kres-repl/src/export.rs @@ -3,13 +3,20 @@ //! //! For each finding in the store, the export writes: //! -//! //metadata.yaml structured metadata (id, severity, -//! git HEAD sha/subject, cross-refs, -//! symbol and file-section locations) -//! //FINDING.md human-readable full body: summary, -//! mechanism, reproducer, impact, fix -//! sketch, open questions, per-task -//! analysis details +//! /findings//metadata.yaml structured metadata (id, +//! severity, git HEAD sha/ +//! subject, cross-refs, symbol +//! and file-section locations) +//! /findings//FINDING.md human-readable full body: +//! summary, mechanism, +//! reproducer, impact, fix +//! sketch, open questions, +//! per-task analysis details +//! +//! Top-level files at `/` (INDEX.md, index.html, +//! findings-index.py) sit alongside the `findings/` subtree so a +//! GitHub Pages publish of the export keeps the entry-point files +//! at the root and per-finding clutter one level down. //! //! `` is the finding's `id`, sanitized so it's safe as a //! directory name. Collisions after sanitizing get a numeric suffix. @@ -65,6 +72,13 @@ pub async fn run_export(inputs: ExportInputs) -> Result<()> { std::fs::create_dir_all(&output_dir) .with_context(|| format!("creating export dir {}", output_dir.display()))?; + // Per-finding folders live under /findings/ so the + // top-level dir holds only entry-point files (INDEX.md, + // index.html, findings-index.py, …). Created up front so the + // per-tag mkdir below sees an existing parent. + let findings_root = output_dir.join("findings"); + std::fs::create_dir_all(&findings_root) + .with_context(|| format!("creating findings dir {}", findings_root.display()))?; let store = FindingsStore::new(&findings_path) .await @@ -89,7 +103,7 @@ pub async fn run_export(inputs: ExportInputs) -> Result<()> { let mut written = 0usize; for f in &findings { let tag = &id_to_tag[&f.id]; - let finding_dir = output_dir.join(tag); + let finding_dir = findings_root.join(tag); std::fs::create_dir_all(&finding_dir) .with_context(|| format!("creating {}", finding_dir.display()))?; write_metadata_yaml(&finding_dir.join("metadata.yaml"), f, &git, &template)?; @@ -112,11 +126,31 @@ pub async fn run_export(inputs: ExportInputs) -> Result<()> { Ok(()) } -/// Walk `/*/metadata.yaml` and write `/INDEX.md` — one -/// row per finding, grouped by severity (High → Medium → Low), and -/// inside each group ordered by `date` ascending so the -/// longest-standing bug sits at the top. Findings with no date sink -/// to the bottom of their group but remain present. +/// Bundled findings index + search tool. Compiled into the kres +/// binary so it travels with the release; copied into the export +/// directory by [`run_index_script`] on first use, then invoked with +/// `--generate` to refresh INDEX.md and index.html. +/// +/// The script lives at `scripts/findings-index.py` in the source +/// tree. Operators can edit the per-export-dir copy freely — kres +/// won't overwrite it on re-runs of `--export` or `--export-index`. +/// Beyond the regen path kres drives, the same script also exposes +/// `--search QUERY` for ad-hoc filtering of the dir from the shell. +const INDEX_SCRIPT_BODY: &str = include_str!("../../scripts/findings-index.py"); +const INDEX_SCRIPT_NAME: &str = "findings-index.py"; + +/// Install `findings-index.py` into `dir` (if absent) and run it +/// with cwd = `dir`. The script walks `/*/metadata.yaml`, sorts +/// the rows by severity then date then id, and writes both +/// `/INDEX.md` and `/index.html`. kres no longer renders +/// either file directly so operators can iterate on layout, columns, +/// or filters without rebuilding the binary — they edit the per-export +/// copy of the script in place. +/// +/// Returns the expected `INDEX.md` path. Whether the file actually +/// exists on return depends on the script: a missing python3 or a +/// hand-edit that errors out leaves the markdown un-refreshed and +/// `run_index_script` logs a diagnostic to stderr. pub fn run_export_index(dir: &Path) -> Result { if !dir.is_dir() { return Err(anyhow::anyhow!( @@ -124,182 +158,59 @@ pub fn run_export_index(dir: &Path) -> Result { dir.display() )); } - let mut rows: Vec = Vec::new(); - for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - let meta = entry.path().join("metadata.yaml"); - if !meta.exists() { - continue; - } - let yaml = std::fs::read_to_string(&meta) - .with_context(|| format!("reading {}", meta.display()))?; - rows.push(IndexRow { - tag: entry.file_name().to_string_lossy().into_owned(), - id: top_level_scalar(&yaml, "id").unwrap_or_default(), - title: top_level_scalar(&yaml, "title").unwrap_or_default(), - severity: parse_severity(top_level_scalar(&yaml, "severity").as_deref().unwrap_or("")), - status: top_level_scalar(&yaml, "status").unwrap_or_else(|| "active".to_string()), - date: top_level_scalar(&yaml, "date"), - }); - } - rows.sort_by(|a, b| { - // Severity desc (High first); within a tier, oldest date - // first; None dates go to the end of their tier; finally fall - // back to id for determinism. - let sev = severity_sort_key(b.severity).cmp(&severity_sort_key(a.severity)); - if sev != std::cmp::Ordering::Equal { - return sev; - } - match (a.date.as_deref(), b.date.as_deref()) { - (Some(x), Some(y)) => x.cmp(y).then_with(|| a.id.cmp(&b.id)), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.id.cmp(&b.id), - } - }); - - let out_path = dir.join("INDEX.md"); - std::fs::write(&out_path, render_index(&rows)) - .with_context(|| format!("writing {}", out_path.display()))?; - Ok(out_path) -} - -#[derive(Debug)] -struct IndexRow { - tag: String, - id: String, - title: String, - severity: Option, - status: String, - date: Option, + run_index_script(dir); + Ok(dir.join("INDEX.md")) } -fn severity_sort_key(s: Option) -> u8 { - match s { - Some(Severity::High) => 3, - Some(Severity::Medium) => 2, - Some(Severity::Low) => 1, - None => 0, - } -} - -fn parse_severity(s: &str) -> Option { - match s { - "low" => Some(Severity::Low), - "medium" => Some(Severity::Medium), - "high" => Some(Severity::High), - _ => None, - } -} - -/// Parse a top-level scalar field from our generated metadata.yaml. -/// Recognises two shapes: -/// key: "quoted value" -/// key: unquoted-value -/// Ignores indented continuations and nested mappings. Returns the -/// raw string value (quotes and backslash escapes unwrapped). -fn top_level_scalar(yaml: &str, key: &str) -> Option { - let needle = format!("{key}: "); - for line in yaml.lines() { - // Indented lines belong to nested mappings / list items. - if line.starts_with(' ') || line.starts_with('\t') { - continue; +/// Copy the bundled index-html generator into `dir` if it isn't +/// already there, then run it with cwd = `dir`. Failures along +/// either step print a diagnostic to stderr but do not propagate — +/// INDEX.md is already on disk, and the script itself is editable +/// by the operator, so a missing python interpreter or a hand-edited +/// script that errors out shouldn't abort an otherwise-successful +/// export. +fn run_index_script(dir: &Path) { + let script_path = dir.join(INDEX_SCRIPT_NAME); + if !script_path.exists() { + if let Err(e) = std::fs::write(&script_path, INDEX_SCRIPT_BODY) { + eprintln!( + "--export: couldn't install {} ({e})", + script_path.display() + ); + return; } - let Some(rest) = line.strip_prefix(&needle) else { - continue; - }; - let rest = rest.trim(); - if let Some(inner) = rest.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { - return Some(unquote_yaml(inner)); - } - return Some(rest.to_string()); - } - None -} - -/// Reverse of yaml_scalar: unwrap backslash-escapes we know about. -/// Unknown escapes pass through as the literal char. -fn unquote_yaml(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c != '\\' { - out.push(c); - continue; - } - match chars.next() { - Some('\\') => out.push('\\'), - Some('"') => out.push('"'), - Some('n') => out.push('\n'), - Some('r') => out.push('\r'), - Some('t') => out.push('\t'), - Some(other) => out.push(other), - None => out.push('\\'), + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + if let Err(e) = std::fs::set_permissions(&script_path, perms) { + eprintln!( + "--export: couldn't chmod {} ({e})", + script_path.display() + ); + return; + } } + eprintln!("--export: installed {}", script_path.display()); + } + let status = std::process::Command::new(&script_path) + .arg("--generate") + .current_dir(dir) + .status(); + match status { + Ok(s) if s.success() => {} + Ok(s) => eprintln!( + "--export: {} exited with {}", + script_path.display(), + s + ), + Err(e) => eprintln!( + "--export: failed to run {} ({e})", + script_path.display() + ), } - out } -fn render_index(rows: &[IndexRow]) -> String { - let mut out = String::new(); - out.push_str("# kres findings index\n\n"); - let ts = chrono::Utc::now().to_rfc3339(); - out.push_str(&format!("_generated: {ts}_\n\n")); - if rows.is_empty() { - out.push_str("(no findings)\n"); - return out; - } - let (h, m, l, u) = rows - .iter() - .fold((0, 0, 0, 0), |(h, m, l, u), r| match r.severity { - Some(Severity::High) => (h + 1, m, l, u), - Some(Severity::Medium) => (h, m + 1, l, u), - Some(Severity::Low) => (h, m, l + 1, u), - None => (h, m, l, u + 1), - }); - out.push_str(&format!( - "{} finding(s): {} high, {} medium, {} low", - rows.len(), - h, - m, - l - )); - if u > 0 { - out.push_str(&format!(", {u} unknown-severity")); - } - out.push_str("\n\n"); - out.push_str("| Severity | Date | Status | ID | Title |\n"); - out.push_str("|---|---|---|---|---|\n"); - for r in rows { - let sev = r - .severity - .map(|s| match s { - Severity::High => "high", - Severity::Medium => "medium", - Severity::Low => "low", - }) - .unwrap_or("?"); - let date = r.date.as_deref().unwrap_or("—"); - let title = escape_md_table_cell(&r.title); - out.push_str(&format!( - "| {sev} | {date} | {status} | [`{id}`]({tag}/FINDING.md) | {title} |\n", - status = r.status, - id = r.id, - tag = r.tag, - title = title, - )); - } - out -} - -fn escape_md_table_cell(s: &str) -> String { - // Pipes break GFM table cells; newlines break the row. Replace - // both with something that keeps the row intact. - s.replace('|', "\\|").replace('\n', " ") -} /// Disk override wins when it exists and is non-empty; else the /// compiled-in copy. Mirrors the `~/.kres/commands/.md` @@ -990,30 +901,11 @@ mod tests { p } - #[test] - fn top_level_scalar_parses_quoted_and_raw() { - let y = "id: \"race_x\"\nseverity: high\n nested: ignore\nstatus: active\n"; - assert_eq!(top_level_scalar(y, "id").as_deref(), Some("race_x")); - assert_eq!(top_level_scalar(y, "severity").as_deref(), Some("high")); - assert_eq!(top_level_scalar(y, "status").as_deref(), Some("active")); - assert_eq!(top_level_scalar(y, "nested"), None, "indented line ignored"); - assert_eq!(top_level_scalar(y, "missing"), None); - } - - #[test] - fn top_level_scalar_unquotes_escapes() { - let y = "title: \"a \\\"quoted\\\" title\"\n"; - assert_eq!( - top_level_scalar(y, "title").as_deref(), - Some("a \"quoted\" title") - ); - } - #[test] fn export_index_sorts_by_severity_then_date_then_id() { let dir = tmp_dir("export-index"); let write = |tag: &str, body: &str| { - let d = dir.join(tag); + let d = dir.join("findings").join(tag); std::fs::create_dir_all(&d).unwrap(); std::fs::write(d.join("metadata.yaml"), body).unwrap(); }; @@ -1042,11 +934,11 @@ mod tests { // Severity desc, oldest-first within a tier, undated at the // bottom of its tier. let order = [ - "[`a`](a_older_high/FINDING.md)", - "[`b`](b_newer_high/FINDING.md)", - "[`c`](c_no_date_high/FINDING.md)", - "[`d`](d_medium/FINDING.md)", - "[`e`](e_low/FINDING.md)", + "[`a`](findings/a_older_high/FINDING.md)", + "[`b`](findings/b_newer_high/FINDING.md)", + "[`c`](findings/c_no_date_high/FINDING.md)", + "[`d`](findings/d_medium/FINDING.md)", + "[`e`](findings/e_low/FINDING.md)", ]; let mut cursor = 0usize; for want in order { @@ -1060,6 +952,95 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn export_index_renders_subsystem_column() { + // Two findings with subsystem set, one without — the column + // should appear in the header, populated rows show the value, + // and a missing/blank value renders as `—`. + let dir = tmp_dir("export-index-subsystem"); + let write = |tag: &str, body: &str| { + let d = dir.join("findings").join(tag); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("metadata.yaml"), body).unwrap(); + }; + write( + "with_subsystem", + "id: \"a\"\ntitle: \"named\"\nseverity: high\nstatus: active\nsubsystem: \"mm/folio\"\n", + ); + write( + "blank_subsystem", + "id: \"b\"\ntitle: \"blank\"\nseverity: medium\nstatus: active\nsubsystem: \"\"\n", + ); + write( + "no_subsystem", + "id: \"c\"\ntitle: \"missing\"\nseverity: low\nstatus: active\n", + ); + let out = run_export_index(&dir).unwrap(); + let body = std::fs::read_to_string(&out).unwrap(); + assert!( + body.contains("| Severity | Subsystem | Date | Status | ID | Title |"), + "header missing Subsystem column: {body}" + ); + assert!( + body.contains("| high | mm/folio |"), + "populated subsystem cell missing: {body}" + ); + // Both blank and absent subsystem render as the em-dash + // placeholder. Two rows × one em-dash each = 2 occurrences in + // the subsystem column. Each row also has a `—` for date, + // so any cell-level grep is brittle; check the row prefix. + assert!( + body.contains("| medium | — |"), + "blank subsystem should render as em-dash: {body}" + ); + assert!( + body.contains("| low | — |"), + "missing subsystem should render as em-dash: {body}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn export_index_installs_index_script_and_preserves_local_edits() { + // run_export_index installs findings-index.py into the export + // dir on first call, then runs it with --generate. We don't + // assert on the resulting INDEX.md/index.html (that depends + // on python3 being on PATH), but we do assert: + // 1. The script lands at /findings-index.py. + // 2. Its body matches the bundled copy verbatim. + // 3. A subsequent run does NOT overwrite an operator's + // hand-edited copy — the "if not already present" rule. + let dir = tmp_dir("export-index-script-install"); + std::fs::create_dir_all(dir.join("findings/a_high")).unwrap(); + std::fs::write( + dir.join("findings/a_high/metadata.yaml"), + "id: \"a\"\ntitle: \"some bug\"\nseverity: high\nstatus: active\n", + ) + .unwrap(); + run_export_index(&dir).unwrap(); + let script = dir.join("findings-index.py"); + assert!( + script.exists(), + "findings-index.py should be installed in {}", + dir.display() + ); + let body = std::fs::read_to_string(&script).unwrap(); + assert_eq!( + body, INDEX_SCRIPT_BODY, + "first install should match the bundled copy verbatim" + ); + // Operator-edited script body must survive a second run. + let edited = "#!/usr/bin/env python3\nprint('operator edit')\n"; + std::fs::write(&script, edited).unwrap(); + run_export_index(&dir).unwrap(); + let body_after = std::fs::read_to_string(&script).unwrap(); + assert_eq!( + body_after, edited, + "second run must not overwrite an operator-edited script" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn finding_md_related_emits_markdown_link_for_known_ids() { let mut f = finding_sample(); diff --git a/scripts/findings-index.py b/scripts/findings-index.py new file mode 100755 index 0000000..d08dd9f --- /dev/null +++ b/scripts/findings-index.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +"""Index and search a `kres --export` findings tree. + +Two modes, picked by mutually exclusive flags: + + findings-index.py --generate + Walk every `/metadata.yaml` in the current directory, sort + the rows by severity (high → medium → low → unknown) then by + date ascending then by id, and write: + * INDEX.md — markdown table for in-tree browsing. + * index.html — same table with client-side filters for + browser / GitHub Pages viewing. + + findings-index.py --search "" + Print a markdown table — same format as INDEX.md — covering only + the rows the query matches. The query is a space-separated list + of `key:value` clauses, AND-ed together. Recognised keys: + severity: + subsystem: — exact match (em-dash for blank) + status: — exact match + since: — date >= since (undated rows + excluded) + regex: — case-insensitive regex over + the row's text columns + +A copy of this script is installed alongside the exported findings the +first time `kres --export` (or `--export-index`) runs over a directory. +Subsequent runs do not overwrite it — edit it freely to customise the +layout, filters, or styling. +""" + +import argparse +import datetime +import html +import os +import re +import sys + + +SEV_RANK = {"high": 3, "medium": 2, "low": 1} + + +def parse_top_level(yaml_text, key): + """Return the value of a top-level scalar key, or None. + + Mirrors the kres Rust top_level_scalar parser: ignores indented + lines (nested mappings / list items) and unwraps a few backslash + escapes inside double-quoted values. Good enough for the + metadata.yaml shape kres emits. + """ + needle = key + ": " + for line in yaml_text.splitlines(): + if line.startswith(" ") or line.startswith("\t"): + continue + if not line.startswith(needle): + continue + rest = line[len(needle):].strip() + if len(rest) >= 2 and rest.startswith('"') and rest.endswith('"'): + return _unquote(rest[1:-1]) + return rest + return None + + +_ESCAPES = { + "\\\\": "\\", + '\\"': '"', + "\\n": "\n", + "\\r": "\r", + "\\t": "\t", +} + + +def _unquote(s): + out = [] + i = 0 + while i < len(s): + if s[i] == "\\" and i + 1 < len(s): + pair = s[i:i + 2] + out.append(_ESCAPES.get(pair, s[i + 1])) + i += 2 + else: + out.append(s[i]) + i += 1 + return "".join(out) + + +def collect_rows(root): + """Walk `/findings//metadata.yaml` for every finding. + + The per-finding folders live under a `findings/` subtree so the + top of the export dir stays uncluttered (INDEX.md, index.html, + this script, …). Old export trees without the subtree return an + empty list. + """ + rows = [] + findings_root = os.path.join(root, "findings") + if not os.path.isdir(findings_root): + return rows + for name in sorted(os.listdir(findings_root)): + path = os.path.join(findings_root, name) + meta = os.path.join(path, "metadata.yaml") + if not os.path.isdir(path) or not os.path.isfile(meta): + continue + with open(meta, encoding="utf-8") as f: + yaml_text = f.read() + subsystem = parse_top_level(yaml_text, "subsystem") or "" + rows.append({ + "tag": name, + "id": parse_top_level(yaml_text, "id") or "", + "title": parse_top_level(yaml_text, "title") or "", + "severity": (parse_top_level(yaml_text, "severity") or "").strip(), + "status": parse_top_level(yaml_text, "status") or "active", + "date": parse_top_level(yaml_text, "date"), + "subsystem": subsystem if subsystem else None, + }) + return rows + + +def sort_rows(rows): + # Severity desc, undated rows last within their tier, date asc, + # then id for determinism. + rows.sort(key=lambda r: ( + -SEV_RANK.get(r["severity"], 0), + r["date"] is None, + r["date"] or "", + r["id"], + )) + + +def md_escape_cell(s): + """Pipes break GFM table cells; newlines break the row. + + Mirror the kres Rust escape_md_table_cell helper so INDEX.md keeps + its earlier structure exactly: a `|` becomes `\\|` and any newline + is collapsed to a single space. + """ + return s.replace("|", "\\|").replace("\n", " ") + + +def build_markdown(rows): + parts = [] + parts.append("# kres findings index") + parts.append("") + ts = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + parts.append("_generated: {}_".format(ts)) + parts.append("") + if not rows: + parts.append("(no findings)") + return "\n".join(parts) + "\n" + + counts = {"high": 0, "medium": 0, "low": 0, "unknown": 0} + for r in rows: + sev = r["severity"] + counts[sev if sev in counts else "unknown"] += 1 + summary = "{} finding(s): {} high, {} medium, {} low".format( + len(rows), counts["high"], counts["medium"], counts["low"] + ) + if counts["unknown"]: + summary += ", {} unknown-severity".format(counts["unknown"]) + parts.append(summary) + parts.append("") + parts.append("| Severity | Subsystem | Date | Status | ID | Title |") + parts.append("|---|---|---|---|---|---|") + for r in rows: + sev = r["severity"] if r["severity"] in SEV_RANK else "?" + date_display = r["date"] or "—" + subsystem = r["subsystem"] if r["subsystem"] else "—" + parts.append( + "| {sev} | {subsys} | {date} | {status} | " + "[`{id}`](findings/{tag}/FINDING.md) | {title} |".format( + sev=sev, + subsys=md_escape_cell(subsystem), + date=date_display, + status=r["status"], + id=r["id"], + tag=r["tag"], + title=md_escape_cell(r["title"]), + ) + ) + return "\n".join(parts) + "\n" + + +FILTER_SCRIPT = """ +""" + + +def build_html(rows): + e = html.escape + parts = [] + parts.append("") + parts.append('') + parts.append("") + parts.append('') + parts.append("kres findings index") + parts.append("") + parts.append("

kres findings index

") + ts = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + parts.append("

generated: {}

".format(e(ts))) + + if not rows: + parts.append("

(no findings)

") + return "\n".join(parts) + "\n" + + counts = {"high": 0, "medium": 0, "low": 0, "unknown": 0} + for r in rows: + sev = r["severity"] + counts[sev if sev in counts else "unknown"] += 1 + summary = "{} finding(s): {} high, {} medium, {} low".format( + len(rows), counts["high"], counts["medium"], counts["low"] + ) + if counts["unknown"]: + summary += ", {} unknown-severity".format(counts["unknown"]) + parts.append("

{}

".format(e(summary))) + + parts.append('
') + parts.append( + '' + ) + parts.append( + '' + ) + parts.append( + '' + ) + parts.append('') + parts.append( + '' + ) + parts.append('') + parts.append('') + parts.append("
") + + parts.append("") + for col in ["Severity", "Subsystem", "Date", "Status", "ID", "Title"]: + parts.append("".format(col)) + parts.append("") + + for r in rows: + sev = r["severity"] if r["severity"] in SEV_RANK else "?" + date_display = r["date"] or "—" + date_attr = r["date"] or "" + subsystem = r["subsystem"] if r["subsystem"] else "—" + haystack = " ".join( + [sev, subsystem, date_display, r["status"], r["id"], r["title"]] + ) + parts.append( + ''.format( + sev=e(sev), + subsys=e(subsystem), + status=e(r["status"]), + date=e(date_attr), + haystack=e(haystack), + ) + ) + parts.append("".format(e(sev))) + parts.append("".format(e(subsystem))) + parts.append("".format(e(date_display))) + parts.append("".format(e(r["status"]))) + parts.append( + '' + .format(e(r["tag"]), e(r["id"])) + ) + parts.append("".format(e(r["title"]))) + parts.append("") + + parts.append("
{}
{}{}{}{}{}{}
") + parts.append(FILTER_SCRIPT) + parts.append("") + return "\n".join(parts) + "\n" + + +_QUERY_KEYS = ("severity", "subsystem", "status", "since", "regex") + + +def parse_query(query): + """Parse a search query into a {key: value} dict. + + Tokens are whitespace-separated `key:value` pairs. Unknown keys + or empty values raise ValueError so the operator sees their typo + instead of silently getting the unfiltered list back. + """ + clauses = {} + for tok in query.split(): + if ":" not in tok: + raise ValueError("clause without ':' — {!r}".format(tok)) + key, _, value = tok.partition(":") + if key not in _QUERY_KEYS: + raise ValueError( + "unknown key {!r} (allowed: {})".format( + key, ", ".join(_QUERY_KEYS) + ) + ) + if not value: + raise ValueError("empty value for {}:".format(key)) + clauses[key] = value + return clauses + + +def _row_haystack(r): + sev = r["severity"] if r["severity"] in SEV_RANK else "?" + date_display = r["date"] or "—" + subsystem = r["subsystem"] if r["subsystem"] else "—" + return " ".join( + [sev, subsystem, date_display, r["status"], r["id"], r["title"]] + ) + + +def filter_rows(rows, clauses): + sev_q = clauses.get("severity") + # `severity:unknown` must match rows whose severity isn't one of + # high/medium/low — those render as "?" in the table. + if sev_q == "unknown": + sev_q = "?" + subsys_q = clauses.get("subsystem") + status_q = clauses.get("status") + since_q = clauses.get("since") + pattern = None + if "regex" in clauses: + try: + pattern = re.compile(clauses["regex"], re.IGNORECASE) + except re.error as exc: + raise ValueError("regex invalid: {}".format(exc)) + out = [] + for r in rows: + sev_eff = r["severity"] if r["severity"] in SEV_RANK else "?" + if sev_q and sev_eff != sev_q: + continue + sub_eff = r["subsystem"] if r["subsystem"] else "—" + if subsys_q and sub_eff != subsys_q: + continue + if status_q and r["status"] != status_q: + continue + if since_q: + if not r["date"] or r["date"] < since_q: + continue + if pattern and not pattern.search(_row_haystack(r)): + continue + out.append(r) + return out + + +def main(): + parser = argparse.ArgumentParser( + prog="findings-index.py", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--generate", + action="store_true", + help="walk cwd and write INDEX.md + index.html", + ) + mode.add_argument( + "--search", + metavar="QUERY", + help="print a filtered markdown table to stdout (see module " + "doc for query syntax)", + ) + args = parser.parse_args() + + root = os.getcwd() + rows = collect_rows(root) + sort_rows(rows) + + if args.generate: + md_path = os.path.join(root, "INDEX.md") + with open(md_path, "w", encoding="utf-8") as f: + f.write(build_markdown(rows)) + html_path = os.path.join(root, "index.html") + with open(html_path, "w", encoding="utf-8") as f: + f.write(build_html(rows)) + print( + "wrote {} and {} ({} row(s))".format( + md_path, html_path, len(rows) + ), + file=sys.stderr, + ) + return 0 + + # --search QUERY + try: + clauses = parse_query(args.search) + filtered = filter_rows(rows, clauses) + except ValueError as exc: + print("search: {}".format(exc), file=sys.stderr) + return 2 + sys.stdout.write(build_markdown(filtered)) + return 0 + + +if __name__ == "__main__": + sys.exit(main() or 0) From 1ed37b2577076b76e498e7e1ffc3bcd6cf8c9979 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 12:07:29 -0700 Subject: [PATCH 66/76] git: add commit-msg hook rejecting lines over 100 chars Several recent commits in this branch shipped 200+ char single-line paragraphs through the `-m` flag, ignoring the 72-char wrap rule documented in the project's commit-message style guide. A hook makes the requirement enforceable instead of advisory. The hook lives at `.githooks/commit-msg` (so it ships with the repo; opt-in via `git config core.hooksPath .githooks`). It scans each non-comment line of the message file with awk and exits 1 if any line exceeds 100 characters, printing the offending line number, length, and content. 100 is deliberately looser than the 72-char wrap rule so quoted code / shas / URLs that legitimately exceed 72 pass without a fight, while wall-of-text `-m` paragraphs do not. Signed-off-by: Chris Mason --- .githooks/commit-msg | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100755 .githooks/commit-msg diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..9069fe8 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Commit-msg hook for the kres repo. +# +# Rejects any commit whose message has a prose line longer than 100 +# characters. Lines starting with `#` are git's editor template +# comments and are stripped before the message is recorded — they're +# skipped here so the hook matches what git actually stores. +# +# To install: git config core.hooksPath .githooks + +set -euo pipefail + +msg_file="$1" +limit=100 + +bad=$(awk -v limit="$limit" ' + /^#/ { next } + { if (length($0) > limit) printf " line %d (%d chars): %s\n", NR, length($0), $0 } +' "$msg_file") + +if [[ -n "$bad" ]]; then + cat >&2 < Date: Sat, 25 Apr 2026 12:21:02 -0700 Subject: [PATCH 67/76] findings-index: per-subsystem indexes + boolean search expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--search` only handled implicit-AND lists of `key:value` clauses with exact-string matching. Operators couldn't pivot a 100+ finding tree by area without scanning INDEX.md by hand, couldn't OR clauses together, and couldn't approximate-match subsystem strings the LLM wrote freeform into metadata.yaml. Three changes: * `--generate` now also emits `INDEX-.md` per distinct non-empty subsystem value alongside INDEX.md / index.html. Rows with a blank subsystem stay in the umbrella INDEX.md only. Stale `INDEX-*.md` files (from a previous run whose subsystems no longer exist) are removed so a renamed subsystem doesn't leave a dangling file. The filename slug strips non-alphanumeric chars to `-`. * `--search QUERY` parses a boolean expression — `clause`, `clause -a clause` (explicit AND, same as adjacency), `clause -o clause` (OR), and `( … )` for grouping. Stdlib only: no expression-parser library fit the bundled-into-kres / no-pip constraint, so a ~70-line recursive-descent parser does the job. Parens must be whitespace-separated; a literal paren in a regex value goes through `[(]` / `[)]`. * Every clause value except `since:` is now a case-insensitive regex over the matching column (severity / subsystem / status / regex). `since:` keeps its YYYY-MM-DD date-bound semantics. `collect_rows` also picks up a flat-layout fallback: if the export dir has no `findings/` subtree, scan the top level. The row dict gains a `tag_path` field so the markdown / html row-link form emits the right relative path in either layout. Tested against ~/local/kernel-bugs/findings/ (166 rows, 79 distinct subsystem strings — heterogeneity is a metadata.yaml data-quality issue, not a tool bug). All boolean / regex / paren forms exercised; malformed expressions and bad regexes both exit 2 with a one-line diagnostic. Signed-off-by: Chris Mason --- scripts/findings-index.py | 369 +++++++++++++++++++++++++++++--------- 1 file changed, 284 insertions(+), 85 deletions(-) diff --git a/scripts/findings-index.py b/scripts/findings-index.py index d08dd9f..b0dce2b 100755 --- a/scripts/findings-index.py +++ b/scripts/findings-index.py @@ -4,24 +4,42 @@ Two modes, picked by mutually exclusive flags: findings-index.py --generate - Walk every `/metadata.yaml` in the current directory, sort - the rows by severity (high → medium → low → unknown) then by - date ascending then by id, and write: - * INDEX.md — markdown table for in-tree browsing. - * index.html — same table with client-side filters for - browser / GitHub Pages viewing. + Walk every `/metadata.yaml` under the current directory and + write: + * INDEX.md — markdown table, all findings. + * INDEX-.md — one per distinct subsystem value. + * index.html — same table with client-side + filters for browser / GitHub Pages + viewing. + The per-subsystem indexes are generated by default — they let + operators pivot a large tree by area without scanning the whole + list. findings-index.py --search "" Print a markdown table — same format as INDEX.md — covering only - the rows the query matches. The query is a space-separated list - of `key:value` clauses, AND-ed together. Recognised keys: - severity: - subsystem: — exact match (em-dash for blank) - status: — exact match - since: — date >= since (undated rows - excluded) - regex: — case-insensitive regex over - the row's text columns + the rows the query matches. The query is a boolean expression + over `key:value` clauses: + + clause := KEY ':' REGEX + and-expr := clause ( ('-a')? clause )* + or-expr := and-expr ( '-o' and-expr )* + atom := '(' or-expr ')' | clause + + `-a` (and) is the default operator between adjacent clauses; + `-o` (or) is explicit. Parens group sub-expressions and must be + whitespace-separated (use `[(]` / `[)]` inside a regex value). + + All clause values are case-insensitive regular expressions over + the matching column, except `since:` which is a date bound: + + severity: — matches against {high,medium,low,?} + subsystem: — matches against the row's subsystem + ("—" when blank) + status: — matches against the row's status + regex: — matches against the joined row text + (sev + subsystem + date + status + + id + title) + since: — date >= since (undated rows excluded) A copy of this script is installed alongside the exported findings the first time `kres --export` (or `--export-index`) runs over a directory. @@ -87,17 +105,27 @@ def _unquote(s): def collect_rows(root): """Walk `/findings//metadata.yaml` for every finding. - The per-finding folders live under a `findings/` subtree so the - top of the export dir stays uncluttered (INDEX.md, index.html, - this script, …). Old export trees without the subtree return an - empty list. + Prefers the modern `/findings//` layout. Falls back to + the pre-`findings/` flat layout (`//`) when the + `findings/` subdir doesn't exist, so legacy export trees and + operator-curated bug roots still produce an index. The per-row + `tag_path` field records which relative subdirectory the metadata + came from so the markdown / html row-link form points at the + correct FINDING.md path. """ - rows = [] findings_root = os.path.join(root, "findings") - if not os.path.isdir(findings_root): - return rows - for name in sorted(os.listdir(findings_root)): - path = os.path.join(findings_root, name) + if os.path.isdir(findings_root): + scan_root = findings_root + tag_prefix = "findings/" + else: + scan_root = root + tag_prefix = "" + + rows = [] + for name in sorted(os.listdir(scan_root)): + if name == ".git": + continue + path = os.path.join(scan_root, name) meta = os.path.join(path, "metadata.yaml") if not os.path.isdir(path) or not os.path.isfile(meta): continue @@ -106,6 +134,7 @@ def collect_rows(root): subsystem = parse_top_level(yaml_text, "subsystem") or "" rows.append({ "tag": name, + "tag_path": tag_prefix + name, "id": parse_top_level(yaml_text, "id") or "", "title": parse_top_level(yaml_text, "title") or "", "severity": (parse_top_level(yaml_text, "severity") or "").strip(), @@ -167,13 +196,13 @@ def build_markdown(rows): subsystem = r["subsystem"] if r["subsystem"] else "—" parts.append( "| {sev} | {subsys} | {date} | {status} | " - "[`{id}`](findings/{tag}/FINDING.md) | {title} |".format( + "[`{id}`]({tag_path}/FINDING.md) | {title} |".format( sev=sev, subsys=md_escape_cell(subsystem), date=date_display, status=r["status"], id=r["id"], - tag=r["tag"], + tag_path=r["tag_path"], title=md_escape_cell(r["title"]), ) ) @@ -383,8 +412,8 @@ def build_html(rows): parts.append("{}".format(e(date_display))) parts.append("{}".format(e(r["status"]))) parts.append( - '{}' - .format(e(r["tag"]), e(r["id"])) + '
{}' + .format(e(r["tag_path"]), e(r["id"])) ) parts.append("{}".format(e(r["title"]))) parts.append("") @@ -396,30 +425,7 @@ def build_html(rows): _QUERY_KEYS = ("severity", "subsystem", "status", "since", "regex") - - -def parse_query(query): - """Parse a search query into a {key: value} dict. - - Tokens are whitespace-separated `key:value` pairs. Unknown keys - or empty values raise ValueError so the operator sees their typo - instead of silently getting the unfiltered list back. - """ - clauses = {} - for tok in query.split(): - if ":" not in tok: - raise ValueError("clause without ':' — {!r}".format(tok)) - key, _, value = tok.partition(":") - if key not in _QUERY_KEYS: - raise ValueError( - "unknown key {!r} (allowed: {})".format( - key, ", ".join(_QUERY_KEYS) - ) - ) - if not value: - raise ValueError("empty value for {}:".format(key)) - clauses[key] = value - return clauses +_REGEX_KEYS = ("severity", "subsystem", "status", "regex") def _row_haystack(r): @@ -431,38 +437,230 @@ def _row_haystack(r): ) -def filter_rows(rows, clauses): - sev_q = clauses.get("severity") - # `severity:unknown` must match rows whose severity isn't one of - # high/medium/low — those render as "?" in the table. - if sev_q == "unknown": - sev_q = "?" - subsys_q = clauses.get("subsystem") - status_q = clauses.get("status") - since_q = clauses.get("since") - pattern = None - if "regex" in clauses: - try: - pattern = re.compile(clauses["regex"], re.IGNORECASE) - except re.error as exc: - raise ValueError("regex invalid: {}".format(exc)) +# --- search expression AST + parser ----------------------------------- +# +# Grammar: +# or_expr := and_expr ( '-o' and_expr )* +# and_expr := atom ( ('-a')? atom )* # implicit AND between atoms +# atom := '(' or_expr ')' | clause +# clause := KEY ':' VALUE +# +# All clause values are case-insensitive regular expressions over the +# matching column, except `since:` which compares as a date string. +# Stdlib only — no third-party expression library that fits the +# bundled-into-kres / no-pip-install constraint. + + +class _Clause: + def __init__(self, key, value): + self.key = key + self.value = value + if key in _REGEX_KEYS: + try: + self.pattern = re.compile(value, re.IGNORECASE) + except re.error as exc: + raise ValueError( + "invalid regex for {}: {}".format(key, exc) + ) + else: + self.pattern = None + + def matches(self, row): + if self.key == "severity": + sev_eff = row["severity"] if row["severity"] in SEV_RANK else "?" + return self.pattern.search(sev_eff) is not None + if self.key == "subsystem": + sub_eff = row["subsystem"] if row["subsystem"] else "—" + return self.pattern.search(sub_eff) is not None + if self.key == "status": + return self.pattern.search(row["status"]) is not None + if self.key == "regex": + return self.pattern.search(_row_haystack(row)) is not None + if self.key == "since": + return bool(row["date"]) and row["date"] >= self.value + return False + + +class _And: + def __init__(self, parts): + self.parts = parts + + def matches(self, row): + return all(p.matches(row) for p in self.parts) + + +class _Or: + def __init__(self, parts): + self.parts = parts + + def matches(self, row): + return any(p.matches(row) for p in self.parts) + + +def _tokenize(query): + """Whitespace-split, with `(` and `)` carved out as standalone + tokens when they appear at a token's leading or trailing edge. + + A regex value that needs a literal paren must escape it with a + character class — `regex:foo[(]bar` — because the tokenizer + splits parens that bookend a token. + """ + raw = query.split() + tokens = [] + for t in raw: + while t.startswith("("): + tokens.append("(") + t = t[1:] + trailing = [] + while t.endswith(")"): + trailing.append(")") + t = t[:-1] + if t: + tokens.append(t) + tokens.extend(trailing) + return tokens + + +def _make_clause(token): + if ":" not in token: + raise ValueError("expected key:value, got {!r}".format(token)) + key, _, value = token.partition(":") + if key not in _QUERY_KEYS: + raise ValueError( + "unknown key {!r} (allowed: {})".format( + key, ", ".join(_QUERY_KEYS) + ) + ) + if not value: + raise ValueError("empty value for {}:".format(key)) + return _Clause(key, value) + + +def parse_query(query): + """Parse a query string into an AST root node. + + The AST exposes a single `.matches(row)` method; callers feed + each row from `collect_rows` and keep the rows where matches + returns True. + """ + tokens = _tokenize(query) + pos = [0] + + def peek(): + return tokens[pos[0]] if pos[0] < len(tokens) else None + + def consume(): + t = tokens[pos[0]] + pos[0] += 1 + return t + + def parse_or(): + parts = [parse_and()] + while peek() == "-o": + consume() + parts.append(parse_and()) + return parts[0] if len(parts) == 1 else _Or(parts) + + def parse_and(): + parts = [parse_atom()] + while peek() not in (None, ")", "-o"): + if peek() == "-a": + consume() + parts.append(parse_atom()) + return parts[0] if len(parts) == 1 else _And(parts) + + def parse_atom(): + t = peek() + if t is None: + raise ValueError("unexpected end of expression") + if t == "(": + consume() + inner = parse_or() + if peek() != ")": + raise ValueError("expected ')'") + consume() + return inner + if t == ")": + raise ValueError("unexpected ')'") + if t in ("-a", "-o"): + raise ValueError( + "operator {!r} without a left-hand clause".format(t) + ) + return _make_clause(consume()) + + if not tokens: + raise ValueError("empty query") + expr = parse_or() + if peek() is not None: + raise ValueError("trailing tokens: {!r}".format(tokens[pos[0]:])) + return expr + + +def filter_rows(rows, expr): + """Return rows where `expr.matches(row)` is True.""" + return [r for r in rows if expr.matches(r)] + + +# --- per-subsystem index generation ----------------------------------- + + +def sanitize_subsystem(name): + """Turn an arbitrary subsystem string into a filename-safe slug. + + Non-alphanumeric / non-(`-`,`_`,`.`) characters collapse to `-`. + Leading/trailing dashes are stripped. An all-symbols subsystem + becomes `unknown`. + """ out = [] + last_dash = False + for c in name: + if c.isalnum() or c in "-_.": + out.append(c) + last_dash = False + elif not last_dash: + out.append("-") + last_dash = True + slug = "".join(out).strip("-") + return slug or "unknown" + + +def build_subsystem_indexes(rows, root): + """Write one INDEX-.md per distinct subsystem value. + + Rows with no subsystem are excluded from the per-subsystem files + (they still appear in the umbrella INDEX.md). Returns the list of + paths written. Cleans up stale `INDEX-*.md` files that no longer + match a current subsystem so renaming a subsystem in metadata.yaml + doesn't leave a dangling index. + """ + grouped = {} for r in rows: - sev_eff = r["severity"] if r["severity"] in SEV_RANK else "?" - if sev_q and sev_eff != sev_q: - continue - sub_eff = r["subsystem"] if r["subsystem"] else "—" - if subsys_q and sub_eff != subsys_q: - continue - if status_q and r["status"] != status_q: - continue - if since_q: - if not r["date"] or r["date"] < since_q: - continue - if pattern and not pattern.search(_row_haystack(r)): + sub = r["subsystem"] + if not sub: continue - out.append(r) - return out + grouped.setdefault(sub, []).append(r) + + written = [] + for sub, sub_rows in sorted(grouped.items()): + slug = sanitize_subsystem(sub) + path = os.path.join(root, "INDEX-{}.md".format(slug)) + with open(path, "w", encoding="utf-8") as f: + f.write(build_markdown(sub_rows)) + written.append(path) + + # Garbage-collect stale per-subsystem indexes. + keep = {os.path.basename(p) for p in written} + for name in os.listdir(root): + if ( + name.startswith("INDEX-") + and name.endswith(".md") + and name not in keep + ): + try: + os.remove(os.path.join(root, name)) + except OSError: + pass + return written def main(): @@ -496,9 +694,10 @@ def main(): html_path = os.path.join(root, "index.html") with open(html_path, "w", encoding="utf-8") as f: f.write(build_html(rows)) + sub_paths = build_subsystem_indexes(rows, root) print( - "wrote {} and {} ({} row(s))".format( - md_path, html_path, len(rows) + "wrote {} and {} ({} row(s); {} per-subsystem index(es))".format( + md_path, html_path, len(rows), len(sub_paths) ), file=sys.stderr, ) @@ -506,8 +705,8 @@ def main(): # --search QUERY try: - clauses = parse_query(args.search) - filtered = filter_rows(rows, clauses) + expr = parse_query(args.search) + filtered = filter_rows(rows, expr) except ValueError as exc: print("search: {}".format(exc), file=sys.stderr) return 2 From f687c5dbd0b477434affe3700496550afb61bb67 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 12:26:05 -0700 Subject: [PATCH 68/76] findings-index: drop the auto per-subsystem INDEX-.md fanout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating one INDEX-.md per distinct subsystem string sounded useful, but on a real tree it produced 79 files for 166 findings — the LLM writes subsystem strings freeform into metadata.yaml, so values like "AMD-CCP-PSP-SEV-SNP", "AMD-CCP-SEV-SNP", and "AMD-SEV-SNP-PSP-CCP-driver" each get their own file. Net effect: the export root went from clean to noisy, with no clear pivot benefit over `--search subsystem:`. Drop the auto-fanout. `--generate` writes INDEX.md and index.html, nothing else. Operators who want subsystem filtering use `--search subsystem:` from the shell, or the in-page filter dropdown in index.html. The `build_subsystem_indexes` and `sanitize_subsystem` helpers go with the auto-fanout — there's no remaining caller and no flag to invoke them manually. Signed-off-by: Chris Mason --- scripts/findings-index.py | 78 ++++----------------------------------- 1 file changed, 7 insertions(+), 71 deletions(-) diff --git a/scripts/findings-index.py b/scripts/findings-index.py index b0dce2b..b3e343e 100755 --- a/scripts/findings-index.py +++ b/scripts/findings-index.py @@ -6,14 +6,11 @@ findings-index.py --generate Walk every `/metadata.yaml` under the current directory and write: - * INDEX.md — markdown table, all findings. - * INDEX-.md — one per distinct subsystem value. - * index.html — same table with client-side - filters for browser / GitHub Pages - viewing. - The per-subsystem indexes are generated by default — they let - operators pivot a large tree by area without scanning the whole - list. + * INDEX.md — markdown table covering all findings. + * index.html — same table with client-side filters for + browser / GitHub Pages viewing. + Use `--search subsystem:` to pivot a large tree by area + without scanning the whole list. findings-index.py --search "" Print a markdown table — same format as INDEX.md — covering only @@ -601,66 +598,6 @@ def filter_rows(rows, expr): return [r for r in rows if expr.matches(r)] -# --- per-subsystem index generation ----------------------------------- - - -def sanitize_subsystem(name): - """Turn an arbitrary subsystem string into a filename-safe slug. - - Non-alphanumeric / non-(`-`,`_`,`.`) characters collapse to `-`. - Leading/trailing dashes are stripped. An all-symbols subsystem - becomes `unknown`. - """ - out = [] - last_dash = False - for c in name: - if c.isalnum() or c in "-_.": - out.append(c) - last_dash = False - elif not last_dash: - out.append("-") - last_dash = True - slug = "".join(out).strip("-") - return slug or "unknown" - - -def build_subsystem_indexes(rows, root): - """Write one INDEX-.md per distinct subsystem value. - - Rows with no subsystem are excluded from the per-subsystem files - (they still appear in the umbrella INDEX.md). Returns the list of - paths written. Cleans up stale `INDEX-*.md` files that no longer - match a current subsystem so renaming a subsystem in metadata.yaml - doesn't leave a dangling index. - """ - grouped = {} - for r in rows: - sub = r["subsystem"] - if not sub: - continue - grouped.setdefault(sub, []).append(r) - - written = [] - for sub, sub_rows in sorted(grouped.items()): - slug = sanitize_subsystem(sub) - path = os.path.join(root, "INDEX-{}.md".format(slug)) - with open(path, "w", encoding="utf-8") as f: - f.write(build_markdown(sub_rows)) - written.append(path) - - # Garbage-collect stale per-subsystem indexes. - keep = {os.path.basename(p) for p in written} - for name in os.listdir(root): - if ( - name.startswith("INDEX-") - and name.endswith(".md") - and name not in keep - ): - try: - os.remove(os.path.join(root, name)) - except OSError: - pass - return written def main(): @@ -694,10 +631,9 @@ def main(): html_path = os.path.join(root, "index.html") with open(html_path, "w", encoding="utf-8") as f: f.write(build_html(rows)) - sub_paths = build_subsystem_indexes(rows, root) print( - "wrote {} and {} ({} row(s); {} per-subsystem index(es))".format( - md_path, html_path, len(rows), len(sub_paths) + "wrote {} and {} ({} row(s))".format( + md_path, html_path, len(rows) ), file=sys.stderr, ) From 0da396f00aa9a90183a62f32f5d46ffce23a0080 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 12:29:31 -0700 Subject: [PATCH 69/76] findings-index: prefer summary.md over FINDING.md in row links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a finding has been triaged, summary.md is the document an operator wants to land on — it's the short triage write-up. FINDING.md is the long-form raw evidence; landing there from a spreadsheet-style index makes the operator scroll past code blocks and call chains they didn't ask for. `collect_rows` now sets `link_file` per row: `summary.md` when that file is present in the finding directory, `FINDING.md` otherwise. The markdown and html row-link emitters use that field, so a half-triaged tree gets a mix — newly-summarised findings link to the summary, untriaged ones still link to the underlying FINDING.md. Signed-off-by: Chris Mason --- scripts/findings-index.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/findings-index.py b/scripts/findings-index.py index b3e343e..0facd7b 100755 --- a/scripts/findings-index.py +++ b/scripts/findings-index.py @@ -129,9 +129,16 @@ def collect_rows(root): with open(meta, encoding="utf-8") as f: yaml_text = f.read() subsystem = parse_top_level(yaml_text, "subsystem") or "" + # Prefer the triage summary when it's been written; fall back + # to the raw FINDING.md so untriaged rows still link somewhere. + if os.path.isfile(os.path.join(path, "summary.md")): + link_file = "summary.md" + else: + link_file = "FINDING.md" rows.append({ "tag": name, "tag_path": tag_prefix + name, + "link_file": link_file, "id": parse_top_level(yaml_text, "id") or "", "title": parse_top_level(yaml_text, "title") or "", "severity": (parse_top_level(yaml_text, "severity") or "").strip(), @@ -193,13 +200,14 @@ def build_markdown(rows): subsystem = r["subsystem"] if r["subsystem"] else "—" parts.append( "| {sev} | {subsys} | {date} | {status} | " - "[`{id}`]({tag_path}/FINDING.md) | {title} |".format( + "[`{id}`]({tag_path}/{link}) | {title} |".format( sev=sev, subsys=md_escape_cell(subsystem), date=date_display, status=r["status"], id=r["id"], tag_path=r["tag_path"], + link=r["link_file"], title=md_escape_cell(r["title"]), ) ) @@ -409,8 +417,8 @@ def build_html(rows): parts.append("{}".format(e(date_display))) parts.append("{}".format(e(r["status"]))) parts.append( - '{}' - .format(e(r["tag_path"]), e(r["id"])) + '{}' + .format(e(r["tag_path"]), e(r["link_file"]), e(r["id"])) ) parts.append("{}".format(e(r["title"]))) parts.append("") From 4ddbd4d91ff0388a13e64a92484777fec2111399 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 13:03:55 -0700 Subject: [PATCH 70/76] kres-repl: install README.md alongside findings-index.py on --export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kres --export` and `--export-index` produce a tree of per-finding folders plus entry-point files (INDEX.md, index.html, findings-index.py). An operator landing in the directory has no in-tree pointer telling them what `metadata.yaml` / `FINDING.md` / `summary.md` mean or how to drive `findings-index.py --search`. Bundle a `scripts/export-README.md` into the kres binary and copy it into the export dir on first run, with the same don't-overwrite contract as `findings-index.py`: an operator-edited README survives re-runs of `--export` / `--export-index`. The README covers the directory layout, the meaning of the per-finding files, and the `--generate` / `--search` syntax (boolean expressions, regex clauses, examples, error behaviour). `install_export_readme` lives next to `run_index_script` in kres-repl/src/export.rs and is called from `run_export_index`, so both kres entry points install the README. Failures log to stderr but don't propagate — the README is documentation, not load-bearing for the export. New test `export_index_installs_readme_and_preserves_ local_edits` pins the bundled body on first install and the operator-edit survival on re-run. Bundled with three drive-by lint cleanups the pre-commit hook flagged in the same area: * rustfmt on an `assert!` block in kres-core/src/io.rs and a chained `Style::default()` call in kres-repl/src/tui.rs; * Rust 1.94's `clippy::question_mark` lint on `if dir.parent().is_none() { return None; }` in `ConsentStore::grant_from_mention` — replaced with the bare `dir.parent()?;` form. Path::parent returns None only at filesystem root, so the early-return semantics match exactly. Signed-off-by: Chris Mason --- kres-core/src/consent.rs | 11 ++-- kres-core/src/io.rs | 10 ++- kres-repl/src/export.rs | 88 ++++++++++++++++++++------ kres-repl/src/tui.rs | 18 +++--- scripts/export-README.md | 133 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 scripts/export-README.md diff --git a/kres-core/src/consent.rs b/kres-core/src/consent.rs index 4d4e6ea..b51d3be 100644 --- a/kres-core/src/consent.rs +++ b/kres-core/src/consent.rs @@ -62,9 +62,9 @@ impl ConsentStore { // also filters this token, but leaves the rule here as // belt-and-braces for any future caller that resolves a path // outside grant_paths_from_text.) - if dir.parent().is_none() { - return None; - } + // `Path::parent` returns None only at the filesystem root, + // so the `?` here propagates the rejection cleanly. + dir.parent()?; let mut g = self.granted.write().unwrap(); g.insert(dir.clone()); Some(dir) @@ -380,7 +380,10 @@ mod tests { Path::new("/tmp"), "lists of `relevant_symbols` / `relevant_file_sections` and yes / no / n/a", ); - assert!(added.is_empty(), "bare separators must not grant: {added:?}"); + assert!( + added.is_empty(), + "bare separators must not grant: {added:?}" + ); assert!(!s.is_allowed(Path::new("/etc/passwd"))); } diff --git a/kres-core/src/io.rs b/kres-core/src/io.rs index cf8516a..320a18d 100644 --- a/kres-core/src/io.rs +++ b/kres-core/src/io.rs @@ -328,12 +328,10 @@ mod tests { let first_mark: Arc> = Arc::new(Mutex::new(0)); let fm = first_mark.clone(); - assert!( - install_markdown_sink(Box::new(move |_| { - *fm.lock().unwrap() = 1; - })) - .is_ok() - ); + assert!(install_markdown_sink(Box::new(move |_| { + *fm.lock().unwrap() = 1; + })) + .is_ok()); // Second installer: must be rejected. let second = install_markdown_sink(Box::new(|_| {})); assert!(second.is_err(), "second install must bounce"); diff --git a/kres-repl/src/export.rs b/kres-repl/src/export.rs index f620c6b..a9add96 100644 --- a/kres-repl/src/export.rs +++ b/kres-repl/src/export.rs @@ -139,6 +139,13 @@ pub async fn run_export(inputs: ExportInputs) -> Result<()> { const INDEX_SCRIPT_BODY: &str = include_str!("../../scripts/findings-index.py"); const INDEX_SCRIPT_NAME: &str = "findings-index.py"; +/// Bundled README explaining the export layout and how to use +/// findings-index.py. Same install discipline as the script: copied +/// into the export dir on first use; never overwritten on re-run, so +/// operator edits survive a kres rebuild. +const EXPORT_README_BODY: &str = include_str!("../../scripts/export-README.md"); +const EXPORT_README_NAME: &str = "README.md"; + /// Install `findings-index.py` into `dir` (if absent) and run it /// with cwd = `dir`. The script walks `/*/metadata.yaml`, sorts /// the rows by severity then date then id, and writes both @@ -158,10 +165,28 @@ pub fn run_export_index(dir: &Path) -> Result { dir.display() )); } + install_export_readme(dir); run_index_script(dir); Ok(dir.join("INDEX.md")) } +/// Install the bundled `README.md` into `dir` if it isn't already +/// there. Same don't-overwrite rule as `run_index_script`: an +/// operator-edited README survives kres re-runs. Failures log to +/// stderr but do not propagate — the README is documentation, not +/// load-bearing for the export. +fn install_export_readme(dir: &Path) { + let readme_path = dir.join(EXPORT_README_NAME); + if readme_path.exists() { + return; + } + if let Err(e) = std::fs::write(&readme_path, EXPORT_README_BODY) { + eprintln!("--export: couldn't install {} ({e})", readme_path.display()); + return; + } + eprintln!("--export: installed {}", readme_path.display()); +} + /// Copy the bundled index-html generator into `dir` if it isn't /// already there, then run it with cwd = `dir`. Failures along /// either step print a diagnostic to stderr but do not propagate — @@ -173,10 +198,7 @@ fn run_index_script(dir: &Path) { let script_path = dir.join(INDEX_SCRIPT_NAME); if !script_path.exists() { if let Err(e) = std::fs::write(&script_path, INDEX_SCRIPT_BODY) { - eprintln!( - "--export: couldn't install {} ({e})", - script_path.display() - ); + eprintln!("--export: couldn't install {} ({e})", script_path.display()); return; } #[cfg(unix)] @@ -184,10 +206,7 @@ fn run_index_script(dir: &Path) { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o755); if let Err(e) = std::fs::set_permissions(&script_path, perms) { - eprintln!( - "--export: couldn't chmod {} ({e})", - script_path.display() - ); + eprintln!("--export: couldn't chmod {} ({e})", script_path.display()); return; } } @@ -199,19 +218,11 @@ fn run_index_script(dir: &Path) { .status(); match status { Ok(s) if s.success() => {} - Ok(s) => eprintln!( - "--export: {} exited with {}", - script_path.display(), - s - ), - Err(e) => eprintln!( - "--export: failed to run {} ({e})", - script_path.display() - ), + Ok(s) => eprintln!("--export: {} exited with {}", script_path.display(), s), + Err(e) => eprintln!("--export: failed to run {} ({e})", script_path.display()), } } - /// Disk override wins when it exists and is non-empty; else the /// compiled-in copy. Mirrors the `~/.kres/commands/.md` /// convention used by `user_commands`, but under @@ -1041,6 +1052,42 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn export_index_installs_readme_and_preserves_local_edits() { + // run_export_index installs README.md alongside the script. + // Same don't-overwrite contract: the bundled copy lands on + // first run, an operator edit survives a re-run. + let dir = tmp_dir("export-index-readme-install"); + std::fs::create_dir_all(dir.join("findings/a_high")).unwrap(); + std::fs::write( + dir.join("findings/a_high/metadata.yaml"), + "id: \"a\"\ntitle: \"some bug\"\nseverity: high\nstatus: active\n", + ) + .unwrap(); + run_export_index(&dir).unwrap(); + let readme = dir.join("README.md"); + assert!( + readme.exists(), + "README.md should be installed in {}", + dir.display() + ); + let body = std::fs::read_to_string(&readme).unwrap(); + assert_eq!( + body, EXPORT_README_BODY, + "first install should match the bundled README verbatim" + ); + // Operator-edited README must survive a second run. + let edited = "# my custom export README\n"; + std::fs::write(&readme, edited).unwrap(); + run_export_index(&dir).unwrap(); + let body_after = std::fs::read_to_string(&readme).unwrap(); + assert_eq!( + body_after, edited, + "second run must not overwrite an operator-edited README" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn finding_md_related_emits_markdown_link_for_known_ids() { let mut f = finding_sample(); @@ -1075,7 +1122,10 @@ mod tests { out.contains("filename: \"drivers/net/x.c\""), "missing filename: {out}" ); - assert!(out.contains("subsystem: \"\"\n"), "missing subsystem: {out}"); + assert!( + out.contains("subsystem: \"\"\n"), + "missing subsystem: {out}" + ); } #[test] diff --git a/kres-repl/src/tui.rs b/kres-repl/src/tui.rs index 2c8adda..c817614 100644 --- a/kres-repl/src/tui.rs +++ b/kres-repl/src/tui.rs @@ -215,9 +215,7 @@ pub const MD_BLOCK_END: &str = "\x01kres-md-block-end\x01"; /// no headings, lists, emphasis, or links. pub fn render_markdown_block(body: &str) -> Vec> { let code_style = Style::default().fg(Color::Cyan); - let fence_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::DIM); + let fence_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM); let mut out: Vec> = Vec::new(); let mut in_fence = false; for line in body.split('\n') { @@ -268,10 +266,7 @@ fn split_inline_code(line: &str, code_style: Style) -> Vec> { break; }; let close = open + 1 + close_rel; - spans.push(Span::styled( - line[open + 1..close].to_string(), - code_style, - )); + spans.push(Span::styled(line[open + 1..close].to_string(), code_style)); cursor = close + 1; } spans @@ -1538,11 +1533,12 @@ mod tests { let body = "before\n```\ncode a\ncode b\n```\nafter"; let lines = render_markdown_block(body); let code_style = Style::default().fg(Color::Cyan); - let fence_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::DIM); + let fence_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM); let texts: Vec = lines.iter().map(line_plain_text).collect(); - assert_eq!(texts, vec!["before", "```", "code a", "code b", "```", "after"]); + assert_eq!( + texts, + vec!["before", "```", "code a", "code b", "```", "after"] + ); // Fence markers are dim-cyan, enclosed lines are plain cyan, // prose lines carry no Cyan styling. assert_eq!(lines[0].spans[0].style, Style::default(), "prose unstyled"); diff --git a/scripts/export-README.md b/scripts/export-README.md new file mode 100644 index 0000000..a59288e --- /dev/null +++ b/scripts/export-README.md @@ -0,0 +1,133 @@ +# kres export tree + +This directory was produced by `kres --export`. It holds one folder +per finding plus a few entry-point files to help you browse and +filter the set. + +## Layout + +``` +. +├── README.md this file +├── INDEX.md markdown index of every finding +├── index.html same index, in-browser with filters +├── findings-index.py regenerate the indexes / run searches +└── findings/ + └── / + ├── metadata.yaml structured per-finding metadata + ├── FINDING.md raw long-form analysis (auto-generated) + └── summary.md triage write-up (operator-driven, optional) +``` + +`` is the finding's `id` from kres, sanitised so it is safe as +a directory name. `INDEX.md` and `index.html` link each row to +`summary.md` when one exists, falling back to `FINDING.md` for +findings that have not been triaged yet. + +## File meanings + +### `findings//metadata.yaml` + +Structured metadata about a single finding: `id`, `title`, +`severity` (high / medium / low), `status` (active / invalidated), +`filename`, `subsystem`, `git.sha` / `git.subject` for the workspace +HEAD at analysis time, optional `introduced_by`, `date`, the +`first_seen_task` / `last_updated_task` pair, related-finding +cross-references, and lists of `relevant_symbols` and +`relevant_file_sections`. Generated by `kres --export`; the only +field a triage flow updates in place is `subsystem`. + +### `findings//FINDING.md` + +The long-form analysis: summary, mechanism, reproducer sketch, +impact, fix sketch, open questions, per-task analysis details, and +the relevant symbol / file excerpts. This is the underlying evidence +the slow agent produced. Treat it as read-only. + +### `findings//summary.md` + +A short triage write-up: subject, status (Fixed / Plausible / +Unknown / Invalid), subsystem one-liner, plain-language impact, +trigger requirements, and a synopsis. Written by the embedded +`triage` slash-command (`kres --prompt 'triage: '`), or by +hand. Optional — a finding without `summary.md` has not been +triaged. + +## findings-index.py + +`findings-index.py` regenerates `INDEX.md` and `index.html` from +the current state of the `findings/` subtree, and supports ad-hoc +search from the shell. + +### Regenerating the indexes + +``` +./findings-index.py --generate +``` + +Walks every `findings//metadata.yaml`, sorts by severity (high +→ medium → low → unknown), then by date ascending, then by id, and +rewrites `INDEX.md` and `index.html` in place. Run it after +`metadata.yaml` edits or after triage adds a `summary.md`. + +### Searching + +``` +./findings-index.py --search "" +``` + +Prints a markdown table — same shape as `INDEX.md` — covering only +the rows that match the query. + +`` is a boolean expression over `key:value` clauses: + + | grammar | meaning | + | ------------- | ------------------------------------ | + | `clause` | `KEY:VALUE` — one filter | + | `c1 c2` | implicit AND | + | `c1 -a c2` | explicit AND | + | `c1 -o c2` | OR | + | `( … )` | grouping (parens are whitespace- | + | | separated tokens; use `[(]` / `[)]` | + | | inside a regex) | + +Recognised keys (every value is a case-insensitive regex except +`since:`, which is a date bound): + + | key | matches | + | ----------- | ------------------------------------------------ | + | `severity` | one of `high` / `medium` / `low` / `?` | + | `subsystem` | the `subsystem:` field (`—` when blank) | + | `status` | `active` / `invalidated` | + | `regex` | the joined row text (sev + subsys + date + | + | | status + id + title) | + | `since` | YYYY-MM-DD; row's date must be ≥ this | + +Examples: + +``` +# every high-severity uaf +./findings-index.py --search "severity:high regex:uaf" + +# anything in the workqueue or scheduler +./findings-index.py --search "subsystem:work -o subsystem:sched" + +# medium+high in the mm subsystem since April +./findings-index.py --search \ + "( severity:high -o severity:medium ) -a subsystem:mm -a since:2026-04-01" + +# loose regex — match any subsystem string starting with "btr" +./findings-index.py --search "subsystem:^btr" +``` + +A bad regex or a malformed expression exits 2 with a one-line +diagnostic on stderr; the table is not printed. + +## Editing the script + +`findings-index.py` is bundled into the kres binary and copied here +on the first `kres --export` (or `kres --export-index`). Subsequent +runs do *not* overwrite it — change the layout, columns, filters, +or styling locally without losing your edits across kres rebuilds. +Same goes for this README. To pick up a newer bundled copy, delete +the local file and re-run kres. From 83adda7342d6df68acf8b528368a0247ceaea986 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 13:19:40 -0700 Subject: [PATCH 71/76] export-README: link to INDEX.md from the top of the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's job is to orient an operator landing in an export tree. The single most useful link from there is to INDEX.md — that's the table of every finding, and what most readers will want before they read prose about file layout and search syntax. Add a one-line `→ [Browse the findings index](INDEX.md)` link between the heading and the first prose paragraph so a GitHub Pages render of the README puts the obvious entry point one click away. Signed-off-by: Chris Mason --- scripts/export-README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/export-README.md b/scripts/export-README.md index a59288e..844bad8 100644 --- a/scripts/export-README.md +++ b/scripts/export-README.md @@ -1,5 +1,7 @@ # kres export tree +→ **[Browse the findings index](INDEX.md)** + This directory was produced by `kres --export`. It holds one folder per finding plus a few entry-point files to help you browse and filter the set. From a6518b2a413132b2096312d41d0d8553f360d483 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sat, 25 Apr 2026 13:58:52 -0700 Subject: [PATCH 72/76] triage-template: cross-link FINDING.md / metadata.yaml from summary.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `INDEX.md` and `index.html` link rows to `summary.md` once a finding has been triaged. The summary itself, though, was a dead-end: a reader who wanted to drop into the long-form FINDING.md or check something in metadata.yaml had to leave the page and navigate the filesystem. Both files sit in the same directory, so a relative link costs nothing. Have the triage prompt emit a one-line cross-link header before the `# Subject:` heading: [FINDING.md](FINDING.md) | [metadata.yaml](metadata.yaml) The Rules section's "no heading above Subject" guidance is unchanged in spirit — only this verbatim cross-link line is allowed before `# Subject:`. Signed-off-by: Chris Mason --- configs/prompts/triage-template.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/configs/prompts/triage-template.md b/configs/prompts/triage-template.md index 78183b9..6afc332 100644 --- a/configs/prompts/triage-template.md +++ b/configs/prompts/triage-template.md @@ -48,7 +48,14 @@ Use exactly the section headings below, in this order. Every section is required. Keep prose tight — short triage doc, not a re-run of FINDING.md. +The very first line of `summary.md` is a relative-link header that +points back at the per-finding `FINDING.md` and `metadata.yaml` +sitting in the same directory. Emit it verbatim, then a blank line, +then the `# Subject:` heading. + ``` +[FINDING.md](FINDING.md) | [metadata.yaml](metadata.yaml) + # Subject: # Status @@ -132,7 +139,10 @@ determined which subsystem this bug belongs to, fill in that subsystem field. ## Rules - The Subject line is the `# Subject:` heading itself — don't add a - separate first heading above it. + separate first heading above it. The only line allowed before + `# Subject:` is the verbatim cross-link line + `[FINDING.md](FINDING.md) | [metadata.yaml](metadata.yaml)` followed + by one blank line. - Status values are exactly one of `Fixed`, `Plausible`, `Unknown`, `Invalid`. Match the metadata's `status:` when it's `invalidated` (→ `Invalid`); otherwise pick the best fit from the FINDING.md From 3bf0f3f2e3118757c7e533b022cf59c47624e8a7 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sun, 26 Apr 2026 04:45:15 -0700 Subject: [PATCH 73/76] scripts: add a generic xargs-timeout.py runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several wrapper scripts (triage-all.py, the bug-batch runners) want the same shape: read a file of inputs one per line, append each input to a command template, run them in parallel with a per-task timeout, and tear every subprocess group down cleanly on Ctrl-C. The pattern lives in ~/local/src/review-prompts/kernel/scripts/claude_xargs.py — same control flow, but with claude-specific naming (cmd template called "claude command", inputs called "SHAs", `--series` / `--append` flags for prompt construction) baked in. Add a generic equivalent at scripts/xargs-timeout.py. Same parallel ThreadPoolExecutor + SIGINT/SIGTERM teardown + SIGTERM → SIGKILL escalation, with the claude knowledge stripped: `--command` / `--input-file` / `--parallel` / `--timeout` / `--verbose`. Blank lines and `#` comment lines in the input file are skipped. Each line is appended to the template and the result runs through `/bin/sh -c`, so shell metacharacters work the same way claude_xargs treated them. Smoke-tested with both the success path (3-input echo list) and the timeout-escalation path (sleep 0.1 vs sleep 5 with --timeout 1). Signed-off-by: Chris Mason --- scripts/xargs-timeout.py | 334 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100755 scripts/xargs-timeout.py diff --git a/scripts/xargs-timeout.py b/scripts/xargs-timeout.py new file mode 100755 index 0000000..e0859f2 --- /dev/null +++ b/scripts/xargs-timeout.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Run a command template against many inputs in parallel, with a +per-invocation timeout and clean-shutdown signal handling. + +Reads one input per line from a file, substitutes the line into the +command template, and runs the resulting shell command. The template +either contains the xargs-style placeholder `{}` (substituted at +every occurrence) or omits it (the input is appended to the end with +a single space). Up to `--parallel` invocations execute concurrently. +SIGINT or SIGTERM on this script tears down every active subprocess +group with a SIGTERM → grace → SIGKILL escalation, so an aborted +batch doesn't leave orphaned processes behind. + +Lines starting with `#` and blank lines in the input file are +skipped. The command runs through `/bin/sh -c` (`shell=True`) so +shell metacharacters in the template work; treat the input file as +trusted for the same reason. +""" + +import argparse +import os +import signal +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Event, Lock + + +# Global state for signal handling. +shutdown_event = Event() +active_processes = [] +processes_lock = Lock() +signal_received = False + + +def signal_handler(signum, frame): + """Handle Ctrl-C / SIGTERM by killing every active subprocess.""" + global signal_received + if signal_received: + return # Avoid multiple invocations + signal_received = True + + print("\n\nInterrupted! Shutting down processes...", file=sys.stderr) + shutdown_event.set() + kill_all_processes() + + +def kill_all_processes(): + """Kill all active processes, first with SIGTERM, then SIGKILL.""" + with processes_lock: + procs = list(active_processes) + + if not procs: + return + + print( + f"Sending SIGTERM to {len(procs)} process group(s)...", + file=sys.stderr, + ) + for proc in procs: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, OSError): + pass + + # Wait up to 5 seconds for graceful termination. + deadline = time.time() + 5 + while time.time() < deadline: + with processes_lock: + still_running = [p for p in active_processes if p.poll() is None] + if not still_running: + break + time.sleep(0.1) + + with processes_lock: + still_running = [p for p in active_processes if p.poll() is None] + + if still_running: + print( + f"Sending SIGKILL to {len(still_running)} process group(s)...", + file=sys.stderr, + ) + for proc in still_running: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, OSError): + pass + + print("Waiting for all processes to exit...", file=sys.stderr) + for proc in procs: + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + print( + f"Process {proc.pid} did not exit, forcing...", + file=sys.stderr, + ) + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + proc.wait(timeout=5) + except (ProcessLookupError, OSError, subprocess.TimeoutExpired): + pass + + print("All processes terminated.", file=sys.stderr) + + +PLACEHOLDER_ARG = "{}" +PLACEHOLDER_NUM = "{#}" # GNU parallel convention for "job number" + + +def expand_template(cmd_template, arg, line_no): + """Substitute the input + line number into the command template. + + Recognised placeholders (any number of occurrences each): + + * `{}` — replaced with the input line (xargs `-I{}` style). + * `{#}` — replaced with the 1-based line number, counted after + blanks and `#` comments are filtered out (so it lines + up with the order the runner submits work). Useful + for per-input output dirs: `--results runs/{#}`. + + If neither placeholder is present, `arg` is appended to the end of + the template with a single space — the legacy append form. + """ + has_arg = PLACEHOLDER_ARG in cmd_template + has_num = PLACEHOLDER_NUM in cmd_template + if not has_arg and not has_num: + return f"{cmd_template} {arg}" + out = cmd_template + if has_num: + out = out.replace(PLACEHOLDER_NUM, str(line_no)) + if has_arg: + out = out.replace(PLACEHOLDER_ARG, arg) + return out + + +def run_one(cmd_template, arg, line_no, timeout): + """Run a single shell command formed by substituting `arg` and + `line_no` into `cmd_template`. + + Returns (arg, return_code, stdout, stderr). + """ + if shutdown_event.is_set(): + return (arg, -1, "", "Shutdown requested before start") + + cmd = expand_template(cmd_template, arg, line_no) + proc = None + try: + proc = subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + preexec_fn=os.setsid, # new pgrp → kill_all can take it down cleanly + ) + + with processes_lock: + active_processes.append(proc) + + try: + stdout, stderr = proc.communicate(timeout=timeout) + return (arg, proc.returncode, stdout, stderr) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + time.sleep(1) + if proc.poll() is None: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + proc.wait() + except (ProcessLookupError, OSError): + pass + return (arg, -1, "", f"Timeout after {timeout} seconds") + except Exception as exc: + return (arg, -1, "", str(exc)) + finally: + if proc is not None: + with processes_lock: + if proc in active_processes: + active_processes.remove(proc) + + +def main(): + parser = argparse.ArgumentParser( + description="Run a command template against many inputs in parallel.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Append form: input goes at the end of the template. + %(prog)s -n 4 -c 'kres --prompt "triage:"' -f bugs.txt --timeout 1800 + + # `{}` placeholder: inserts the input where you want it. Useful when + # the input has to land mid-command, e.g. as a `--results` value. + %(prog)s -n 4 -c 'kres --results runs/{} --prompt {}' -f bugs.txt + + # `{#}` placeholder: 1-based line number (after `#`/blank lines are + # filtered out). Pair with `{}` to keep per-input output dirs + # short even when the inputs themselves are long paths. + %(prog)s -n 4 -c 'kres --results runs/{#} --prompt {}' -f bugs.txt + + # Print each input verbatim (useful as a smoke test). + %(prog)s -c 'echo' -f inputs.txt + + # Echo failing lines' stderr along with the progress markers. + %(prog)s -n 8 -c './run-one.sh' -f tasks.txt -v + """, + ) + parser.add_argument( + "-c", "--command", + required=True, + help="command template; each input line is appended to it before " + "running the result through `/bin/sh -c`", + ) + parser.add_argument( + "-f", "--input-file", + required=True, + help="file with one input per line (blank lines and lines " + "starting with `#` are skipped)", + ) + parser.add_argument( + "-n", "--parallel", + type=int, + default=24, + help="number of parallel invocations (default: 24)", + ) + parser.add_argument( + "--timeout", + type=int, + help="per-invocation timeout in seconds (default: no timeout)", + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="print stderr from failed invocations", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="print the fully-expanded command that would run for each " + "input, then exit. Nothing is executed, no signal handlers " + "installed.", + ) + + args = parser.parse_args() + + # Read inputs. + try: + with open(args.input_file, "r") as f: + inputs = [ + line.strip() + for line in f + if line.strip() and not line.lstrip().startswith("#") + ] + except OSError as exc: + print(f"Error opening {args.input_file}: {exc}", file=sys.stderr) + return 1 + if not inputs: + print( + f"Error: no inputs found in {args.input_file}", + file=sys.stderr, + ) + return 1 + print( + f"Loaded {len(inputs)} input(s) from {args.input_file}", + file=sys.stderr, + ) + + if args.dry_run: + # Show the fully-expanded command per input on stdout, one + # per line, so the operator can pipe / inspect it. No signal + # handlers, no executor, no subprocesses. + for line_no, arg in enumerate(inputs, start=1): + print(expand_template(args.command, arg, line_no)) + print( + f"--dry-run: {len(inputs)} command(s) would run", + file=sys.stderr, + ) + return 0 + + # Wire signal handlers. + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + completed = 0 + failed = 0 + try: + with ThreadPoolExecutor(max_workers=max(1, args.parallel)) as executor: + futures = { + executor.submit( + run_one, args.command, arg, line_no, args.timeout + ): arg + for line_no, arg in enumerate(inputs, start=1) + } + for future in as_completed(futures): + if shutdown_event.is_set(): + break + + arg, returncode, stdout, stderr = future.result() + completed += 1 + bar = "=" * 60 + if returncode == 0: + print(f"\n{bar}\nCompleted: {arg}\n{bar}") + print(stdout) + else: + failed += 1 + print(f"\n{bar}", file=sys.stderr) + print( + f"FAILED: {arg} (exit code: {returncode})", + file=sys.stderr, + ) + print(f"{bar}", file=sys.stderr) + if args.verbose and stderr: + print(stderr, file=sys.stderr) + + print( + f"Progress: {completed}/{len(inputs)} (failed: {failed})", + file=sys.stderr, + ) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + finally: + if shutdown_event.is_set() and not signal_received: + kill_all_processes() + + print( + f"\nCompleted: {completed}/{len(inputs)}, Failed: {failed}", + file=sys.stderr, + ) + return 1 if failed > 0 or shutdown_event.is_set() else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 75498a8ff6a6b8571a1a5dc79c828c4e81bb1c1d Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sun, 26 Apr 2026 15:10:45 -0700 Subject: [PATCH 74/76] findings-index: load saved subset indexes from index-config.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--search QUERY` is great for one-shot pivots, but the operator- useful subsets (`severity:high`, `subsystem:work`, `regex:uaf -a since:...`) get re-run every triage cycle. Letting `--generate` materialise them into named markdown files keeps the saved set in the export tree and means a GitHub Pages publish picks them up. `--generate` now also reads `/index-config.yaml`, when present, and writes one markdown file per `{file, query}` entry. Format is a list of two-key mappings: - file: INDEX-high.md query: severity:high - file: INDEX-recent-uaf.md query: regex:uaf -a since:2026-04-01 Stdlib has no YAML reader and the script can't take a pip dependency (it bundles into the kres binary and copies into export dirs), so the parser is a ~30-line hand-roll for the limited shape. `query` reuses the existing `parse_query` so any expression that works under `--search` works in the config. Filenames are validated as plain basenames inside the export dir — `..`, `/`, absolute paths, and leading `.` are refused so a typo can't write outside the tree. A bad regex or malformed query reports a one-line stderr diagnostic, skips that file, and continues. The exit code is 1 if any entry errored. The umbrella INDEX.md and index.html are always written; the config-driven files land on top. Absent config → silent skip. Documented in scripts/export-README.md alongside the existing `--search` syntax. Verified: absent config (umbrella only), valid 3-entry config (row counts match), malformed query (skip + exit 1), and suspicious filenames (refused — no files outside the export dir). Signed-off-by: Chris Mason --- scripts/export-README.md | 34 ++++++++ scripts/findings-index.py | 174 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 202 insertions(+), 6 deletions(-) diff --git a/scripts/export-README.md b/scripts/export-README.md index 844bad8..f0c90c4 100644 --- a/scripts/export-README.md +++ b/scripts/export-README.md @@ -125,6 +125,40 @@ Examples: A bad regex or a malformed expression exits 2 with a one-line diagnostic on stderr; the table is not printed. +### Saved subset indexes (`index-config.yaml`) + +Drop a file at `./index-config.yaml` to have `--generate` emit one +markdown index per entry every time it runs. The format is a list of +`{file, query}` mappings — one block per output file: + +```yaml +# Saved subset indexes regenerated on every `findings-index.py --generate`. +- file: INDEX-high.md + query: severity:high + +- file: INDEX-workqueue.md + query: subsystem:work + +- file: INDEX-recent-uaf.md + query: regex:uaf -a since:2026-04-01 +``` + +Rules: + +- `file:` is a plain basename inside the export dir. `..`, `/`, and + hidden files (leading `.`) are refused so a typo can't write + outside the tree. +- `query:` is the same expression syntax as `--search QUERY` + (key:regex clauses, `-a` / `-o` / parens). +- Lines starting with `#` and blank lines are skipped. +- The umbrella `INDEX.md` and `index.html` are always written; the + config adds files on top. +- A bad regex or malformed query reports a one-line error to + stderr, skips that file, and continues with the rest. The script + exits 1 if any entry errored. + +The config itself is operator-managed — kres doesn't ship one. + ## Editing the script `findings-index.py` is bundled into the kres binary and copied here diff --git a/scripts/findings-index.py b/scripts/findings-index.py index 0facd7b..23c5238 100755 --- a/scripts/findings-index.py +++ b/scripts/findings-index.py @@ -6,11 +6,13 @@ findings-index.py --generate Walk every `/metadata.yaml` under the current directory and write: - * INDEX.md — markdown table covering all findings. - * index.html — same table with client-side filters for - browser / GitHub Pages viewing. - Use `--search subsystem:` to pivot a large tree by area - without scanning the whole list. + * INDEX.md — markdown table covering all findings. + * index.html — same table with client-side filters + for browser / GitHub Pages viewing. + * INDEX-.md — one per `{file, query}` entry in + `index-config.yaml`, if present. + Use `--search subsystem:` for ad-hoc pivots that don't + need a saved file. findings-index.py --search "" Print a markdown table — same format as INDEX.md — covering only @@ -606,6 +608,154 @@ def filter_rows(rows, expr): return [r for r in rows if expr.matches(r)] +# --- index-config.yaml: per-export named subset indexes ---------------- +# +# Stdlib has no YAML reader and the script can't add a pip dependency +# (it bundles into the kres binary and copies into export dirs). The +# config has a tiny fixed shape — a list of `{file, query}` mappings +# — so a hand-rolled minimal parser handles it. +# +# Sample contents: +# +# # ~/local/kernel-bugs/index-config.yaml +# - file: INDEX-high.md +# query: severity:high +# - file: INDEX-workqueue.md +# query: subsystem:work +# - file: INDEX-recent-uaf.md +# query: regex:uaf -a since:2026-04-01 +# +# Filenames must be plain basenames inside the export dir — no `..`, +# no slashes, no absolute paths. Each query goes through the same +# parser as `--search QUERY` and produces a markdown table identical +# in shape to INDEX.md. + +CONFIG_FILENAME = "index-config.yaml" + + +def _strip_yaml_quotes(s): + s = s.strip() + if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def _kv(line, lineno): + if ":" not in line: + raise ValueError( + "{}: expected `key: value`, got {!r}".format(lineno, line) + ) + key, _, value = line.partition(":") + return key.strip(), _strip_yaml_quotes(value) + + +def parse_index_config(path): + """Parse a list of `- file: X\\n query: Y` blocks. + + Returns `[]` when the file is missing — the config is optional. + Raises ValueError on malformed input so the operator sees their + typo instead of getting silent no-ops. + """ + if not os.path.isfile(path): + return [] + entries = [] + cur = None + with open(path, encoding="utf-8") as f: + for lineno, raw in enumerate(f, start=1): + line = raw.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#"): + continue + if line.startswith("- "): + if cur is not None: + entries.append(cur) + cur = {} + rest = line[2:].lstrip() + if rest: + k, v = _kv(rest, lineno) + cur[k] = v + continue + if line.startswith(" ") and cur is not None: + k, v = _kv(line.strip(), lineno) + cur[k] = v + continue + raise ValueError( + "{}: unexpected indentation / no current entry: {!r}".format( + lineno, line + ) + ) + if cur is not None: + entries.append(cur) + return entries + + +def _safe_basename(filename): + """Reject anything that isn't a plain basename inside the export + dir. Path traversal / absolute paths get refused so a typo can't + write outside the tree. + """ + if not filename: + return False + if filename in (".", "..") or "/" in filename or "\\" in filename: + return False + if filename.startswith("."): # hidden file would be confusing + return False + return True + + +def write_custom_indexes(rows, root, config_path): + """Generate one markdown file per `{file, query}` entry in the + index-config.yaml. Returns (written, errors) so main() can report. + """ + try: + entries = parse_index_config(config_path) + except ValueError as exc: + print( + "{}: parse error on line {}".format(config_path, exc), + file=sys.stderr, + ) + return 0, 1 + + written = 0 + errors = 0 + for entry in entries: + filename = entry.get("file", "") + query = entry.get("query", "") + if not filename or not query: + print( + "index-config: entry missing file or query: {!r}".format(entry), + file=sys.stderr, + ) + errors += 1 + continue + if not _safe_basename(filename): + print( + "index-config: refusing suspicious filename " + "{!r} (must be a plain basename inside the export dir)".format( + filename + ), + file=sys.stderr, + ) + errors += 1 + continue + try: + expr = parse_query(query) + except ValueError as exc: + print( + "index-config: query for {!r} invalid: {}".format(filename, exc), + file=sys.stderr, + ) + errors += 1 + continue + filtered = filter_rows(rows, expr) + out_path = os.path.join(root, filename) + with open(out_path, "w", encoding="utf-8") as f: + f.write(build_markdown(filtered)) + print( + " custom: {} ({} row(s))".format(out_path, len(filtered)), + file=sys.stderr, + ) + written += 1 + return written, errors def main(): @@ -645,7 +795,19 @@ def main(): ), file=sys.stderr, ) - return 0 + # Optional per-export named indexes via index-config.yaml. + # Absent config → silently skipped. + config_path = os.path.join(root, CONFIG_FILENAME) + custom_written, custom_errors = write_custom_indexes( + rows, root, config_path + ) + if custom_written or custom_errors: + print( + "index-config: {} custom index(es) written, " + "{} error(s)".format(custom_written, custom_errors), + file=sys.stderr, + ) + return 1 if custom_errors else 0 # --search QUERY try: From 2253a323696040eb325ce78544f798f22b11dd41 Mon Sep 17 00:00:00 2001 From: Chris Mason Date: Sun, 26 Apr 2026 16:14:10 -0700 Subject: [PATCH 75/76] findings-index: add file: and function: search clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five existing query keys (`severity` / `subsystem` / `status` / `since` / `regex`) match against the row's table columns, but operators triaging a tree by area routinely want to pivot on the file or function the finding cites. `regex:` over the joined row text doesn't catch those — file paths and symbol names live in metadata.yaml's `relevant_symbols` / `relevant_file_sections` lists, not in any column the table renders. Add two new clauses with the same case-insensitive-regex semantics the others have: * `file:` matches any filename in the metadata.yaml — the primary `filename:` plus every `relevant_symbols` / `relevant_file_sections` entry. Same set FINDING.md cites in its `Relevant symbols` / `Relevant file sections` blocks. * `function:` matches any symbol name listed under `relevant_symbols:` (functions, macros, types). Same set FINDING.md cites in its `Relevant symbols` block. `collect_rows` extracts both at parse time via `all_filenames()` and `all_function_names()` and stashes them on the row dict so the matchers don't re-walk the YAML per row. The function-name extractor runs a tiny state machine that only collects `name:` lines while inside the `relevant_symbols:` block — kres-emitted metadata.yaml has no other top-level `name:` key, so a fresh-top-level-key check reliably exits the block. Documentation: * Bundled `scripts/export-README.md` gets the two new keys in the search-clause table, four worked `--search` examples (anchored function match, prefix file match, combined `file: -a severity:`, `function: -a severity:`), and two new entries in the saved- index `index-config.yaml` sample (`INDEX-net-ipv4.md` driven by `file:^net/ipv4/`, `INDEX-pwq-symbols.md` driven by `function:^pwq_`). * Top-of-script docstring lists the new clauses alongside the others so `--help` / `head` of the file is current. Verified against ~/local/kernel-bugs/: * `function:^acpi_os_execute$` — exact-match anchor finds the single OSL_DEBUGGER finding citing that symbol. * `function:put_unbound_pool` — substring matches 3 findings. * `file:workqueue.c -a severity:high` — combo finds 2 high workqueue findings. * Malformed regex (`function:[`) exits 2 with the regex error. Signed-off-by: Chris Mason --- scripts/export-README.md | 32 +++++++++++++ scripts/findings-index.py | 95 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/scripts/export-README.md b/scripts/export-README.md index f0c90c4..28dfcc4 100644 --- a/scripts/export-README.md +++ b/scripts/export-README.md @@ -101,6 +101,12 @@ Recognised keys (every value is a case-insensitive regex except | `severity` | one of `high` / `medium` / `low` / `?` | | `subsystem` | the `subsystem:` field (`—` when blank) | | `status` | `active` / `invalidated` | + | `file` | any filename in metadata.yaml — the primary | + | | `filename:` plus every relevant-symbols / | + | | relevant-file-sections entry FINDING.md cites | + | `function` | any symbol name under `relevant_symbols:` | + | | (functions, macros, types — same set FINDING.md | + | | cites in its `Relevant symbols` block) | | `regex` | the joined row text (sev + subsys + date + | | | status + id + title) | | `since` | YYYY-MM-DD; row's date must be ≥ this | @@ -120,8 +126,27 @@ Examples: # loose regex — match any subsystem string starting with "btr" ./findings-index.py --search "subsystem:^btr" + +# every finding that touches a file under net/ipv4/ +./findings-index.py --search "file:^net/ipv4/" + +# exact-match a single function (anchor with ^ and $) +./findings-index.py --search "function:^acpi_os_execute$" + +# any finding that touches a `pwq_*` symbol with severity high +./findings-index.py --search "function:^pwq_ -a severity:high" + +# touched workqueue.c AND high severity +./findings-index.py --search "file:workqueue.c -a severity:high" ``` +`file:` and `function:` both look beyond the visible table cells — +`file:` searches the union of every filename FINDING.md cites (the +primary `filename:` plus everything under `relevant_symbols:` and +`relevant_file_sections:`), and `function:` searches every symbol +name under `relevant_symbols:`. Use anchors (`^foo$`) for exact +matches, `^foo` for prefix, plain `foo` for substring. + A bad regex or a malformed expression exits 2 with a one-line diagnostic on stderr; the table is not printed. @@ -141,6 +166,13 @@ markdown index per entry every time it runs. The format is a list of - file: INDEX-recent-uaf.md query: regex:uaf -a since:2026-04-01 + +# Touch points by file path or symbol — handy for area owners. +- file: INDEX-net-ipv4.md + query: file:^net/ipv4/ + +- file: INDEX-pwq-symbols.md + query: function:^pwq_ ``` Rules: diff --git a/scripts/findings-index.py b/scripts/findings-index.py index 23c5238..c785a55 100755 --- a/scripts/findings-index.py +++ b/scripts/findings-index.py @@ -35,6 +35,17 @@ subsystem: — matches against the row's subsystem ("—" when blank) status: — matches against the row's status + file: — matches against any filename the + metadata.yaml lists (primary + `filename:` plus every + `relevant_symbols` / `relevant_file_ + sections` entry — same set FINDING.md + cites) + function: — matches against any symbol name in + metadata.yaml's `relevant_symbols` + list (functions, macros, types — + same set FINDING.md's `Relevant + symbols` block cites) regex: — matches against the joined row text (sev + subsystem + date + status + id + title) @@ -101,6 +112,64 @@ def _unquote(s): return "".join(out) +def all_filenames(yaml_text): + """Return every distinct `filename:` value referenced anywhere in + a metadata.yaml — top-level (the primary), and all the indented + occurrences under `relevant_symbols` / `relevant_file_sections`. + Same set FINDING.md cites in its `Relevant symbols` and + `Relevant file sections` blocks. + """ + out = set() + for line in yaml_text.splitlines(): + stripped = line.lstrip() + if not stripped.startswith("filename:"): + continue + rest = stripped[len("filename:"):].strip() + if not rest: + continue + if rest.startswith('"') and rest.endswith('"') and len(rest) >= 2: + rest = _unquote(rest[1:-1]) + if rest: + out.add(rest) + return sorted(out) + + +def all_function_names(yaml_text): + """Return every distinct symbol name listed under + `relevant_symbols:` — i.e. the function / macro / type names + FINDING.md's `Relevant symbols` section cites. + + A small state machine tracks whether we're inside the + `relevant_symbols:` block. The kres-emitted metadata.yaml has no + other top-level `name:` key, so a simple "we just hit a fresh + top-level key" check leaves the section cleanly. + """ + out = set() + in_symbols = False + for line in yaml_text.splitlines(): + if line.startswith("relevant_symbols:"): + in_symbols = True + continue + if line and not line[0].isspace() and ":" in line: + in_symbols = False + continue + if not in_symbols: + continue + stripped = line.strip() + # Either `- name: foo` or ` name: foo` lands here. + if stripped.startswith("- name:"): + value = stripped[len("- name:"):].strip() + elif stripped.startswith("name:"): + value = stripped[len("name:"):].strip() + else: + continue + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + value = _unquote(value[1:-1]) + if value: + out.add(value) + return sorted(out) + + def collect_rows(root): """Walk `/findings//metadata.yaml` for every finding. @@ -147,6 +216,8 @@ def collect_rows(root): "status": parse_top_level(yaml_text, "status") or "active", "date": parse_top_level(yaml_text, "date"), "subsystem": subsystem if subsystem else None, + "filenames": all_filenames(yaml_text), + "functions": all_function_names(yaml_text), }) return rows @@ -431,8 +502,12 @@ def build_html(rows): return "\n".join(parts) + "\n" -_QUERY_KEYS = ("severity", "subsystem", "status", "since", "regex") -_REGEX_KEYS = ("severity", "subsystem", "status", "regex") +_QUERY_KEYS = ( + "severity", "subsystem", "status", "since", "regex", "file", "function", +) +_REGEX_KEYS = ( + "severity", "subsystem", "status", "regex", "file", "function", +) def _row_haystack(r): @@ -481,6 +556,22 @@ def matches(self, row): return self.pattern.search(sub_eff) is not None if self.key == "status": return self.pattern.search(row["status"]) is not None + if self.key == "file": + # Match against any filename the metadata.yaml lists — + # primary `filename:` plus every relevant_symbols / + # relevant_file_sections entry. One hit wins. + return any( + self.pattern.search(fn) is not None + for fn in row.get("filenames", []) + ) + if self.key == "function": + # Match against any symbol name listed under + # `relevant_symbols:` (function / macro / type). One hit + # wins. + return any( + self.pattern.search(fn) is not None + for fn in row.get("functions", []) + ) if self.key == "regex": return self.pattern.search(_row_haystack(row)) is not None if self.key == "since": From 5997c8ae829fd9073dce076bff2665855ef0b496 Mon Sep 17 00:00:00 2001 From: Breno Leitao Date: Mon, 27 Apr 2026 10:12:36 -0700 Subject: [PATCH 76/76] kres-repl: halt on N consecutive task errors and surface them to the todo agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed symptom — every fast/slow call returns 400 immediately: API status 400: max_tokens: 164000 > 128000, which is the maximum allowed number of output tokens for claude-sonnet-4-6 400 isn't in is_retryable_status, so the call returns Err right away and the task lands in Errored with empty analysis. The reaper then feeds that empty analysis to the todo agent, which reads "no analysis" as "task didn't run" and re-queues the same item forever with reason "Prior execution returned empty analysis; must re-run." Session 6f3f0daf-… spun like that for 50 min — 269 failed slow calls, 0 successes, output dir untouched, tasks #42 through #625 reshuffling the same 11 todo items. Add a consecutive-error watchdog to the reaper: after K=3 reaped Errored tasks in a row, print a loud banner with the last error verbatim, drain pending/blocked todos to /followup so /continue can resume after the operator fixes the root cause, and (under --one) cancel the root shutdown. Reset on any Done — a single success means the pipeline is alive. Also pass [task errored: ] (instead of "") to the todo agent when r.state == Errored, so even before the watchdog trips the agent has something concrete to react to instead of treating the empty string as "nothing happened, queue it again." Signed-off-by: Breno Leitao --- kres-repl/src/session.rs | 61 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/kres-repl/src/session.rs b/kres-repl/src/session.rs index c85d1ba..45c10ec 100644 --- a/kres-repl/src/session.rs +++ b/kres-repl/src/session.rs @@ -871,6 +871,17 @@ impl Session { // count strictly increases. let mut no_new_findings_streak: u32 = 0; const NO_NEW_FINDINGS_STOP: u32 = 3; + // Watchdog: if N consecutive reaped tasks come back Errored, + // the pipeline is busted (revoked key, dead model, network + // dropped, etc.) and re-queueing the same items via the todo + // agent just burns API budget. Bail loudly. Reset on any + // Done reap — a single success means things are working. + // Without this, sessions like .kres/logs/6f3f0daf-… (269 + // failed slow calls in 50 min, 0 successes) silently spin + // forever because the todo agent keeps re-queueing + // "Prior execution returned empty analysis; must re-run." + let mut consecutive_errors: u32 = 0; + const MAX_CONSECUTIVE_ERRORS: u32 = 3; // Latch for the --turns 0 auto-stop banner. The stop check // below runs on every 250ms tick, but the operator only // wants to SEE "goal met" once; re-firing it every tick @@ -1311,7 +1322,21 @@ impl Session { if let Some(ref tc) = todo_client { let current = mgr_for_reaper.todo_snapshot().await; let completed_query = r.name.clone(); - let analysis = r.analysis.clone(); + // Errored tasks reach this path with + // analysis="". Without surfacing the error + // here the todo agent reads "no analysis" as + // "task didn't run, re-queue" and we spin + // (see consecutive_errors comment above). + // Inject the error so the agent has something + // concrete to react to (skip vs. retry). + let analysis = if matches!(r.state, TaskState::Errored) { + format!( + "[task errored: {}]", + r.error.as_deref().unwrap_or("(no error text)") + ) + } else { + r.analysis.clone() + }; let followups = r.followups.clone(); kres_core::async_eprintln!( "[todo update] before: {} item(s) ({} pending, {} done); {} new followup(s)", @@ -1536,6 +1561,40 @@ impl Session { } } } + // Consecutive-error watchdog: surface a busted + // pipeline instead of letting the todo agent + // re-queue the same items forever (see counter + // declaration above for context). + match r.state { + TaskState::Errored => { + consecutive_errors = consecutive_errors.saturating_add(1); + } + TaskState::Done => { + consecutive_errors = 0; + } + _ => {} + } + if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { + kres_core::async_eprintln!( + "\n=== {consecutive_errors} CONSECUTIVE TASK FAILURES — halting ===\nlast error: {}\ncheck the kres terminal for [rate-limit]/[stream-interrupt] lines, verify the slow/fast API key + model id, then /continue or restart kres --resume.", + r.error.as_deref().unwrap_or("(no error text)") + ); + mgr_for_reaper.reset_in_progress_to_pending().await; + let drained = mgr_for_reaper.drain_pending_blocked().await; + let carry = drained.len(); + let mut deferred = deferred_for_reaper.lock().await; + deferred.extend(drained); + drop(deferred); + if carry > 0 { + kres_core::async_eprintln!( + "[{carry} pending item(s) moved to /followup]" + ); + } + if exit_on_idle { + mgr_for_reaper.root_shutdown().cancel(); + break; + } + } } // --turns N limit: once the slow-agent run count hits // the configured cap, broadcast cancel so the REPL