From 385a8d4b47adb0d3224695cfc60ab721ad963200 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 1 Aug 2026 18:30:20 +0100 Subject: [PATCH 1/2] Answer the refine keys from the queue, not the triage menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine is deliberately not a triage verdict — a proposal that goes through it is still `proposed` — so filing it behind the verdict menu put it under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where `r` and `R` now answer. Refresh moves to `ctrl-r` to free the letter. The triage menu keeps its own pair, so a refine decided mid-verdict costs no escape, and both paths keep the guard that refuses anything but a proposal. The key hint line gains `r/R refine` on a proposal, which is what surfaced this: refine had no hint anywhere, because the hints are computed per screen and the menu that owned the keys is a mode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QUiy1Y4sr5kXmKrYqjaTU --- crates/voro/src/app.rs | 135 ++++++++++++++++++++++++++++++++++++++--- crates/voro/src/cli.rs | 4 +- crates/voro/src/ui.rs | 16 ++++- docs/DESIGN.md | 2 +- 4 files changed, 144 insertions(+), 13 deletions(-) diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 1b140ed..3788b13 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use voro_core::{ Action, ActionRow, AgentsConfig, DepKind, DepRef, DigestRow, Event, PrRef, Priority, Project, Queue, QueueRow, ReviewAction, ReviewMedium, RunningRow, ScoreBreakdown, StateCounts, Store, @@ -207,6 +207,15 @@ pub enum CreateFlow { Plan, } +/// Which refine intensity a keypress asks for (DESIGN.md §6): a one-line note +/// feeding a headless rewrite on `r`, or the interactive planning session on +/// `R` — the same lowercase-default, uppercase-variant pairing as `n`/`N`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefineFlow { + Note, + Interactive, +} + /// A request for main() to suspend the terminal and run $EDITOR. #[derive(Debug, Clone, Copy)] pub enum EditorRequest { @@ -992,10 +1001,12 @@ impl App { _ => {} } match key.code { - KeyCode::Char('r') => { + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { let result = self.refresh(); self.report(result); } + KeyCode::Char('r') => self.refine_selected(RefineFlow::Note), + KeyCode::Char('R') => self.refine_selected(RefineFlow::Interactive), KeyCode::Enter => self.activate_selection(), KeyCode::Char('n') => self.new_task(CreateFlow::Editor), KeyCode::Char('N') => self.new_task(CreateFlow::Plan), @@ -1215,14 +1226,43 @@ impl App { } } - /// Whether a task is still awaiting triage — what gates the triage menu's - /// refine keys. + /// Whether a task is still awaiting triage — what gates the refine keys, + /// on the queue and in the triage menu alike. pub fn is_proposed(&self, task_id: i64) -> bool { self.all .iter() .any(|r| r.task.id == task_id && r.task.state == TaskState::Proposed) } + /// Refine the selected proposal (DESIGN.md §6). Refine is an event on a + /// proposal rather than a verdict on one, so it answers from the queue — + /// where the operator reads the body and notices it is sub-standard — and + /// not only from behind the triage menu, which collects verdicts. A + /// selection that is not a proposal reports why via the status line, the + /// same no-op-with-explanation style as the other action keys. + fn refine_selected(&mut self, flow: RefineFlow) { + let Some(task) = self.selected_task() else { + return; + }; + let (task_id, state) = (task.id, task.state); + if state != TaskState::Proposed { + self.status = Some(format!( + "task is {state} — refine works on a proposal awaiting triage" + )); + return; + } + match flow { + RefineFlow::Note => { + self.mode = Mode::Prompt { + task_id, + kind: PromptKind::RefineNote, + buffer: String::new(), + } + } + RefineFlow::Interactive => self.refine_interactively(task_id), + } + } + /// Note-driven refine (DESIGN.md §6): hand the body, the note, and the /// discovered-from context to a headless agent that rewrites the body in /// place. No transition — the task stays `proposed` and comes back round @@ -2360,6 +2400,10 @@ mod tests { app.on_key(KeyEvent::from(code)); } + fn ctrl_key(app: &mut App, code: KeyCode) { + app.on_key(KeyEvent::new(code, KeyModifiers::CONTROL)); + } + /// A `DispatchCtx` that is never actually used to spawn anything in these /// tests — the transitions they drive (`resume`, reject) only move state. fn dummy_ctx() -> crate::dispatch::DispatchCtx { @@ -2657,8 +2701,7 @@ mod tests { /// The two keystrokes a proposal's triage menu now takes: Enter folds the /// project's digest open, Enter on the proposal beneath it opens the menu. fn open_triage_menu(app: &mut App) -> i64 { - key(app, KeyCode::Enter); - app.move_selection(1); + select_proposal(app); key(app, KeyCode::Enter); match &app.mode { Mode::Transition { task_id, .. } => *task_id, @@ -2666,6 +2709,16 @@ mod tests { } } + /// Select a queued proposal: the cockpit collapses them into a per-project + /// digest (DESIGN.md §7), so Enter folds it open before a constituent row + /// can be selected. + fn select_proposal(app: &mut App) -> i64 { + key(app, KeyCode::Enter); + app.move_selection(1); + app.selected_task_id() + .expect("a folded-open digest should select a proposal") + } + /// `r` in the triage menu collects the note the refine agent is briefed /// with (DESIGN.md §6). The launch itself needs a configured agent, which /// the dummy context has none of, so what is asserted here is the path: the @@ -2713,8 +2766,8 @@ mod tests { assert_eq!(app.store.task(task_id).unwrap().state, TaskState::Proposed); } - /// The refine keys belong to the triage menu alone: on any other state the - /// menu keeps its own bindings, so `r` is not swallowed. + /// Inside the triage menu the refine keys stay gated on a proposal, so on + /// any other state the menu keeps its own bindings and `r` is not swallowed. #[test] fn the_refine_keys_are_inert_outside_a_proposal() { let mut app = app_with(&[TaskState::Ready]); @@ -2726,6 +2779,72 @@ mod tests { ); } + /// Refine answers from the queue, not only from behind the triage menu + /// (DESIGN.md §6): `r` over a selected proposal collects the note directly. + #[test] + fn refine_key_on_the_queue_collects_a_note_without_the_triage_menu() { + let mut app = app_with(&[TaskState::Proposed]); + let task_id = select_proposal(&mut app); + + key(&mut app, KeyCode::Char('r')); + match &app.mode { + Mode::Prompt { + task_id: id, + kind: PromptKind::RefineNote, + .. + } => assert_eq!(*id, task_id), + _ => panic!("r on a queued proposal should open the refine-note prompt"), + } + assert_eq!( + app.store.task(task_id).unwrap().state, + TaskState::Proposed, + "refine never transitions the task" + ); + } + + /// `R` over a queued proposal reaches the interactive variant. The dummy + /// context configures no `plan` verb, so the failure lands on the status + /// line rather than transitioning anything. + #[test] + fn talk_key_on_the_queue_reaches_the_plan_flow() { + let mut app = app_with(&[TaskState::Proposed]); + let task_id = select_proposal(&mut app); + + key(&mut app, KeyCode::Char('R')); + assert!(matches!(app.mode, Mode::Normal)); + assert!(app.pending_plan.is_some() || app.status.is_some()); + assert_eq!(app.store.task(task_id).unwrap().state, TaskState::Proposed); + } + + /// On anything but a proposal the queue's refine keys are a no-op that says + /// why, the same style as the other action keys — not a silent swallow. + #[test] + fn the_queue_refine_keys_explain_themselves_on_a_non_proposal() { + let mut app = app_with(&[TaskState::Ready]); + + key(&mut app, KeyCode::Char('r')); + assert!(matches!(app.mode, Mode::Normal)); + assert!( + app.status.as_deref().is_some_and(|s| s.contains("refine")), + "expected a status line explaining refine, got {:?}", + app.status + ); + } + + /// Refresh keeps a key, one modifier along: `ctrl-r` refreshes and does not + /// fall through to refine, even with a proposal selected. + #[test] + fn ctrl_r_refreshes_rather_than_refining() { + let mut app = app_with(&[TaskState::Proposed]); + select_proposal(&mut app); + + ctrl_key(&mut app, KeyCode::Char('r')); + assert!( + matches!(app.mode, Mode::Normal), + "ctrl-r should not open the refine prompt" + ); + } + #[test] fn tasks_screen_enter_opens_detail_then_transitions() { let mut app = app_with(&[TaskState::Ready]); diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 70d3ac8..86b2412 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -186,7 +186,7 @@ transitions and marked ↻ refined for a re-triage of the improved version. The note-less interactive variant is a conversation with the agent, so - it lives in the TUI's triage menu + it lives in the TUI, on `R` over a proposal start ready → running ask --question TEXT running → needs-input resume needs-input → running, once you have answered @@ -2030,7 +2030,7 @@ fn triage_verb(store: &mut Store, args: TriageArgs, ctx: &DispatchCtx) -> Result return Err(format!( "refine needs a note saying what to fix: `voro triage {} refine --note \ \"...\"`. The note-less interactive variant is a conversation with the \ - agent, so it lives in the TUI's triage menu", + agent, so it lives in the TUI, on `R` over a proposal", args.task_id )); }; diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 60df4b2..511d2f3 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1439,9 +1439,15 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { /// The contextual per-screen key line (ui-redesign §2): the actions that apply /// on the current screen and selection, as key/label pairs the caller renders -/// key-bold, label-dim. It lists actions, not navigation (`j`/`k` and `r` +/// key-bold, label-dim. It lists actions, not navigation (`j`/`k` and `ctrl-r` /// refresh are omitted); `q` and `tab` are always present. Selection-only -/// actions drop out on the cockpit when nothing is selected. +/// actions drop out on the cockpit when nothing is selected, and the refine +/// keys appear only on a proposal, which is all they act on. +/// Whether the selection is a proposal, which is the only thing refine acts on. +fn selection_is_proposed(app: &App) -> bool { + app.selected_task_id().is_some_and(|id| app.is_proposed(id)) +} + fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { // `enter_hint` yields "⏎ "; split the glyph from the verb so the // glyph renders as the bold key and the verb as the dim label. @@ -1457,6 +1463,9 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { pairs.push(("d", "dispatch")); pairs.push(("D", "agent")); } + if selection_is_proposed(app) { + pairs.push(("r/R", "refine")); + } pairs.push(("s", "state")); if app.selected_task_id().is_some() { pairs.push(("!", "deep")); @@ -1485,6 +1494,9 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { if app.selected_can_hand_off() { pairs.push(("w", "wait")); } + if selection_is_proposed(app) { + pairs.push(("r/R", "refine")); + } pairs.push(("s", "state")); pairs.push(("!", "deep")); pairs.push(("n", "new")); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e7d3363..78decef 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -201,7 +201,7 @@ Three deliberate choices. Any triaged, non-terminal state can be *abandoned* str Triage has a fourth outcome that is deliberately *not* a verdict. A proposal whose body is sub-standard leaves the operator three bad options — accept it as it stands, reject it and lose the work, or pay the manual edit cost — and accepting wins by default, which exports the quality problem downstream to dispatch and review. **Refine** is the fourth: `voro triage refine --note "..."` hands the body, the operator's one-line note, the task's linked documents (§3), and the body and completion summary of the task it was `discovered-from` to a headless agent, whose whole job is to rewrite the body as a dispatchable prompt honouring the note and apply it with `voro set --body-file` — the CLI as the agent's interface, exactly as in dispatch and planning. That seed context is pulled in rather than left to the agent to hunt because it is usually precisely what a sloppy proposal is missing: the plan it was meant to implement, and the work it fell out of. The task stays `proposed` throughout: refine is an *event* on a proposed task, not a state, so nothing about the state machine, the queue, or the score changes, and the improved version comes back round for a real verdict on the next pass. A `refined` event carrying the note marks the row `↻ refined` in the task browser, `list`, and `show` until triage takes the task out of `proposed`, so the operator can see which rows have moved since they last read them. In the queue, where proposals collapse into a per-project digest (§7), the constituent rows carry the marker once the digest is folded open and the digest itself carries the count — `↻ 2 refined` — since a collapsed digest would otherwise hide the very fact that its bodies have improved. The event is recorded at the moment the agent is launched rather than when the body lands — Voro is told, not observing (§8), and the same principle that keeps dispatch agent-agnostic applies here: what the marker promises is that a rewrite was asked for, and the rewritten body is what the operator reads. Refine runs on the *default* agent whatever override the task carries, since an agent override picks who executes a task, not who writes its brief, and it opens no session row: like a planning session it is not a dispatch, it moves no task state, and a run that dies having applied nothing leaves the proposal exactly as it was. -Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the triage menu, where `r` collects a note and `R` opens the conversation. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` task, before a prompt is written or a process spawned. +Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the TUI. Both intensities answer directly over a selected proposal — `r` collects a note, `R` opens the conversation — rather than from behind the triage menu, because that menu collects *verdicts* and refine is deliberately not one (above): a proposal that leaves it is still `proposed`, so putting refine there filed an event under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where the key now is. Refresh moves to `ctrl-r` to free the letter, the manual counterpart to the refresh every mutating action already performs. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` task, before a prompt is written or a process spawned — and the triage menu keeps the same pair, so a refine decided mid-verdict costs no escape. The note-driven path is one instance of a general shape: a terse human intent, expanded by an agent into a formal artefact, applied back through an ordinary CLI verb. Expanding a review rejection's one-line feedback the same way is the obvious next instance, so the seed-context-plus-note → agent → apply-via-verb plumbing is factored (`Expansion` in the `voro` crate) rather than written into refine alone. From 0612120a546bdf892c779268d0cddae6f659c715 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 1 Aug 2026 19:02:17 +0100 Subject: [PATCH 2/2] Drop the triage menu's copy of the refine keys Leaving `r`/`R` in the verdict menu as well as the queue kept the claim the move exists to retract: that refine is something triage does. One key in one place, and the guard refusing anything but a proposal lives in the single entry point. The two triage-menu tests go with it. The note-submission path they covered folds into the queue-level test; nothing replaces the rest, since a test that a keybinding is absent pins behaviour that was never a requirement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019QUiy1Y4sr5kXmKrYqjaTU --- crates/voro/src/app.rs | 101 ++++++----------------------------------- docs/DESIGN.md | 2 +- 2 files changed, 15 insertions(+), 88 deletions(-) diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 3788b13..7b4d0f1 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -2220,22 +2220,6 @@ impl App { sel = (sel + 1).min(actions.len().saturating_sub(1)); } KeyCode::Char('k') | KeyCode::Up => sel = sel.saturating_sub(1), - // The fourth triage outcome (DESIGN.md §6), on the two keys the - // menu advertises for a proposal: `r` collects a one-line note for - // a headless rewrite, `R` opens the interactive conversation. Both - // leave the task `proposed`, so neither is a menu verdict. - KeyCode::Char('r') | KeyCode::Char('R') if self.is_proposed(task_id) => { - if key.code == KeyCode::Char('r') { - self.mode = Mode::Prompt { - task_id, - kind: PromptKind::RefineNote, - buffer: String::new(), - }; - } else { - self.refine_interactively(task_id); - } - return; - } KeyCode::Enter => { let action = actions[sel].clone(); let kind = match action { @@ -2698,17 +2682,6 @@ mod tests { assert_eq!(app.enter_hint(), Some("⏎ act")); } - /// The two keystrokes a proposal's triage menu now takes: Enter folds the - /// project's digest open, Enter on the proposal beneath it opens the menu. - fn open_triage_menu(app: &mut App) -> i64 { - select_proposal(app); - key(app, KeyCode::Enter); - match &app.mode { - Mode::Transition { task_id, .. } => *task_id, - _ => panic!("expected the triage menu"), - } - } - /// Select a queued proposal: the cockpit collapses them into a per-project /// digest (DESIGN.md §7), so Enter folds it open before a constituent row /// can be selected. @@ -2719,26 +2692,30 @@ mod tests { .expect("a folded-open digest should select a proposal") } - /// `r` in the triage menu collects the note the refine agent is briefed - /// with (DESIGN.md §6). The launch itself needs a configured agent, which - /// the dummy context has none of, so what is asserted here is the path: the - /// prompt opens, submitting it reaches the dispatch, and the task stays - /// `proposed` either way — refine is an event, not a verdict. + /// Refine answers from the queue (DESIGN.md §6): `r` over a selected + /// proposal collects the note directly. #[test] - fn refine_key_in_the_triage_menu_collects_a_note_and_moves_no_state() { + fn refine_key_on_the_queue_collects_a_note_without_the_triage_menu() { let mut app = app_with(&[TaskState::Proposed]); - let task_id = open_triage_menu(&mut app); + let task_id = select_proposal(&mut app); key(&mut app, KeyCode::Char('r')); match &app.mode { Mode::Prompt { + task_id: id, kind: PromptKind::RefineNote, buffer, - .. - } => assert!(buffer.is_empty(), "buffer was {buffer:?}"), - _ => panic!("r on a proposal should open the refine-note prompt"), + } => { + assert_eq!(*id, task_id); + assert!(buffer.is_empty(), "buffer was {buffer:?}"); + } + _ => panic!("r on a queued proposal should open the refine-note prompt"), } + // The launch itself needs a configured agent, which the dummy context + // has none of, so what is asserted is the path: submitting the note + // reaches the dispatch and reports, and the task stays `proposed` + // either way — refine is an event, not a verdict. for c in "thin".chars() { key(&mut app, KeyCode::Char(c)); } @@ -2752,56 +2729,6 @@ mod tests { ); } - /// `R` opens the interactive variant — the planning harness pointed at the - /// existing task. The dummy context configures no `plan` verb, so the - /// failure lands on the status line rather than transitioning anything. - #[test] - fn talk_key_in_the_triage_menu_reaches_the_plan_flow() { - let mut app = app_with(&[TaskState::Proposed]); - let task_id = open_triage_menu(&mut app); - - key(&mut app, KeyCode::Char('R')); - assert!(matches!(app.mode, Mode::Normal)); - assert!(app.pending_plan.is_some() || app.status.is_some()); - assert_eq!(app.store.task(task_id).unwrap().state, TaskState::Proposed); - } - - /// Inside the triage menu the refine keys stay gated on a proposal, so on - /// any other state the menu keeps its own bindings and `r` is not swallowed. - #[test] - fn the_refine_keys_are_inert_outside_a_proposal() { - let mut app = app_with(&[TaskState::Ready]); - key(&mut app, KeyCode::Enter); - key(&mut app, KeyCode::Char('r')); - assert!( - matches!(app.mode, Mode::Transition { .. }), - "r should not open a refine prompt on a ready task" - ); - } - - /// Refine answers from the queue, not only from behind the triage menu - /// (DESIGN.md §6): `r` over a selected proposal collects the note directly. - #[test] - fn refine_key_on_the_queue_collects_a_note_without_the_triage_menu() { - let mut app = app_with(&[TaskState::Proposed]); - let task_id = select_proposal(&mut app); - - key(&mut app, KeyCode::Char('r')); - match &app.mode { - Mode::Prompt { - task_id: id, - kind: PromptKind::RefineNote, - .. - } => assert_eq!(*id, task_id), - _ => panic!("r on a queued proposal should open the refine-note prompt"), - } - assert_eq!( - app.store.task(task_id).unwrap().state, - TaskState::Proposed, - "refine never transitions the task" - ); - } - /// `R` over a queued proposal reaches the interactive variant. The dummy /// context configures no `plan` verb, so the failure lands on the status /// line rather than transitioning anything. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 78decef..1bc2cc7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -201,7 +201,7 @@ Three deliberate choices. Any triaged, non-terminal state can be *abandoned* str Triage has a fourth outcome that is deliberately *not* a verdict. A proposal whose body is sub-standard leaves the operator three bad options — accept it as it stands, reject it and lose the work, or pay the manual edit cost — and accepting wins by default, which exports the quality problem downstream to dispatch and review. **Refine** is the fourth: `voro triage refine --note "..."` hands the body, the operator's one-line note, the task's linked documents (§3), and the body and completion summary of the task it was `discovered-from` to a headless agent, whose whole job is to rewrite the body as a dispatchable prompt honouring the note and apply it with `voro set --body-file` — the CLI as the agent's interface, exactly as in dispatch and planning. That seed context is pulled in rather than left to the agent to hunt because it is usually precisely what a sloppy proposal is missing: the plan it was meant to implement, and the work it fell out of. The task stays `proposed` throughout: refine is an *event* on a proposed task, not a state, so nothing about the state machine, the queue, or the score changes, and the improved version comes back round for a real verdict on the next pass. A `refined` event carrying the note marks the row `↻ refined` in the task browser, `list`, and `show` until triage takes the task out of `proposed`, so the operator can see which rows have moved since they last read them. In the queue, where proposals collapse into a per-project digest (§7), the constituent rows carry the marker once the digest is folded open and the digest itself carries the count — `↻ 2 refined` — since a collapsed digest would otherwise hide the very fact that its bodies have improved. The event is recorded at the moment the agent is launched rather than when the body lands — Voro is told, not observing (§8), and the same principle that keeps dispatch agent-agnostic applies here: what the marker promises is that a rewrite was asked for, and the rewritten body is what the operator reads. Refine runs on the *default* agent whatever override the task carries, since an agent override picks who executes a task, not who writes its brief, and it opens no session row: like a planning session it is not a dispatch, it moves no task state, and a run that dies having applied nothing leaves the proposal exactly as it was. -Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the TUI. Both intensities answer directly over a selected proposal — `r` collects a note, `R` opens the conversation — rather than from behind the triage menu, because that menu collects *verdicts* and refine is deliberately not one (above): a proposal that leaves it is still `proposed`, so putting refine there filed an event under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where the key now is. Refresh moves to `ctrl-r` to free the letter, the manual counterpart to the refresh every mutating action already performs. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` task, before a prompt is written or a process spawned — and the triage menu keeps the same pair, so a refine decided mid-verdict costs no escape. +Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the TUI. Both intensities answer over a selected proposal in the queue — `r` collects a note, `R` opens the conversation — and *only* there, not from behind the triage menu, because that menu collects *verdicts* and refine is deliberately not one (above): a proposal that leaves it is still `proposed`, so putting refine there filed an event under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where the key is. The menu does not keep a second copy: one key in one place is the whole point of moving it, and a duplicate would reintroduce the claim that refine is something the verdict menu does. Refresh moves to `ctrl-r` to free the letter, the manual counterpart to the refresh every mutating action already performs. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` task, before a prompt is written or a process spawned. The note-driven path is one instance of a general shape: a terse human intent, expanded by an agent into a formal artefact, applied back through an ordinary CLI verb. Expanding a review rejection's one-line feedback the same way is the obvious next instance, so the seed-context-plus-note → agent → apply-via-verb plumbing is factored (`Expansion` in the `voro` crate) rather than written into refine alone.