diff --git a/crates/voro-core/src/store.rs b/crates/voro-core/src/store.rs index 4753a87..8d6d7d8 100644 --- a/crates/voro-core/src/store.rs +++ b/crates/voro-core/src/store.rs @@ -3207,8 +3207,8 @@ mod tests { } } - /// The round's guard rails: a task that is not `proposed` cannot start one, - /// and one that is not `refining` cannot conclude one. + /// The round's guard rails: a task past `proposed`/`ready` cannot start + /// one, and one that is not `refining` cannot conclude one. #[test] fn refine_transitions_are_refused_from_the_wrong_state() { use crate::transition::{Action, Triage}; @@ -3220,13 +3220,13 @@ mod tests { Err(Error::InvalidTransition { .. }) )); - s.apply(t.id, Action::Triage(Triage::Ready)).unwrap(); + s.apply(t.id, Action::Triage(Triage::Parked)).unwrap(); assert!(matches!( s.record_refine_launch(t.id, "too late", "claude", None, None), Err(Error::InvalidTransition { .. }) )); // The refused launch wrote nothing — no session, no state change. - assert_eq!(s.task(t.id).unwrap().state, TaskState::Ready); + assert_eq!(s.task(t.id).unwrap().state, TaskState::Parked); assert!(s.sessions_for(t.id).unwrap().is_empty()); } diff --git a/crates/voro-core/src/transition.rs b/crates/voro-core/src/transition.rs index cbb123a..64583a7 100644 --- a/crates/voro-core/src/transition.rs +++ b/crates/voro-core/src/transition.rs @@ -27,9 +27,9 @@ pub enum Triage { pub enum Action { /// proposed → parked | ready | rejected Triage(Triage), - /// proposed → refining; the string is the operator's refine note, which - /// rides the transition as a `refined` event (empty for the interactive - /// flavour, which is a conversation rather than a brief). + /// proposed | ready → refining; the string is the operator's refine note, + /// which rides the transition as a `refined` event (empty for the + /// interactive flavour, which is a conversation rather than a brief). Refine(String), /// refining → proposed; the round is over, however it ended. ConcludeRefine(RefineOutcome), @@ -167,9 +167,11 @@ impl Store { } /// Refine's atomic write (DESIGN.md §6), the shape [`record_dispatch`] - /// established: move the task `proposed → refining` and open the round's - /// session in one transaction, so a refining task always has a session to - /// probe and a refine session always names a task that is refining. The + /// established: move the task `proposed | ready → refining` and open the + /// round's session in one transaction, so a refining task always has a + /// session to probe and a refine session always names a task that is + /// refining. A round launched from `ready` still concludes to `proposed`, + /// which is what sends the rewritten body back through triage. The /// note rides the transition; the empty string is the interactive flavour, /// which is a conversation rather than a brief. Spawning the process is the /// caller's job, before this commits. @@ -195,8 +197,11 @@ impl Store { /// trigger comes through here — the agent's own `set --body-file` /// (`Applied`), a reconciled dead agent (`Failed`), a quit or cancelled /// session (`Cancelled`) — so the returned proposal's markers read from one - /// place. Refused on a task that is not refining, like any other - /// transition. + /// place. The landing is `proposed` whatever the round started from: the + /// round keeps no memory of its origin, so a task refined out of `ready` + /// comes back for a fresh verdict on the body that replaced the one the old + /// verdict was issued against. Refused on a task that is not refining, like + /// any other transition. pub fn conclude_refine(&mut self, task_id: i64, outcome: RefineOutcome) -> Result { self.apply(task_id, Action::ConcludeRefine(outcome)) } @@ -385,10 +390,11 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result Parked, (Proposed, Action::Triage(Triage::Ready)) => Ready, (Proposed, Action::Triage(Triage::Reject)) => Rejected, - // A refine round: out of the triage queue while an agent rewrites the - // body, back to `proposed` for a real verdict when it concludes - // (DESIGN.md §6). - (Proposed, Action::Refine(_)) => Refining, + // A refine round: out of the queue while an agent rewrites the body, + // back to `proposed` for a real verdict when it concludes — from + // `ready` as much as from `proposed`, since a verdict issued against a + // body that no longer exists has to be reissued (DESIGN.md §6). + (Proposed | Ready, Action::Refine(_)) => Refining, (Refining, Action::ConcludeRefine(_)) => Proposed, (Ready | Stalled, Action::Start) => Running, // A human task cannot be blocked on a decision — the executor *is* the @@ -796,7 +802,7 @@ mod tests { (Proposed, Action::Triage(Triage::Parked)) => Some(Parked), (Proposed, Action::Triage(Triage::Ready)) => Some(Ready), (Proposed, Action::Triage(Triage::Reject)) => Some(Rejected), - (Proposed, Action::Refine(_)) => Some(Refining), + (Proposed | Ready, Action::Refine(_)) => Some(Refining), (Refining, Action::ConcludeRefine(_)) => Some(Proposed), (Ready | Stalled, Action::Start) => Some(Running), (Ready | Stalled, Action::Park) => Some(Parked), @@ -844,7 +850,7 @@ mod tests { /// `legal_actions` is the transition *menu*, so it matches `apply` on every /// action but one: launching a refine is a legal transition the menu /// deliberately withholds, because that menu collects verdicts and refine is - /// not one (DESIGN.md §6) — it answers from its own key over the proposal. + /// not one (DESIGN.md §6) — it answers from its own key over the row. #[test] fn legal_actions_agrees_with_apply() { for state in TaskState::ALL { @@ -879,6 +885,99 @@ mod tests { } } + // --- refine rounds (DESIGN.md §6): where they start and where they land --- + + mod refine { + use super::*; + + /// The note-driven and interactive flavours are one action carrying + /// different notes, so both must open a round from `ready` and both + /// must open its session in the same write. + #[test] + fn a_ready_task_can_be_refined_in_either_flavour() { + for note in ["the body names no files", ""] { + let (mut s, p) = store_with_project(); + let id = create(&mut s, p, TaskState::Ready); + + let (task, session) = s + .record_refine_launch(id, note, "claude", Some(4242), None) + .unwrap(); + assert_eq!(task.state, TaskState::Refining); + assert_eq!(session.task_id, id); + assert!(session.ended_at.is_none()); + assert!( + s.events_for(id) + .unwrap() + .iter() + .any(|e| e.detail.as_deref() == Some("ready -> refining")), + "the transition is logged" + ); + } + } + + /// However the round ends, and whichever state it started from, it + /// lands on `proposed`: the verdict a `ready` task already carried was + /// issued against a body that no longer exists (DESIGN.md §6). + #[test] + fn every_round_concludes_to_proposed_whatever_it_started_from() { + for from in [TaskState::Proposed, TaskState::Ready] { + for outcome in [ + RefineOutcome::Applied, + RefineOutcome::Failed, + RefineOutcome::Cancelled, + ] { + let (mut s, p) = store_with_project(); + let id = create(&mut s, p, from); + s.record_refine_launch(id, "thin body", "claude", Some(1), None) + .unwrap(); + + let task = s.conclude_refine(id, outcome).unwrap(); + assert_eq!(task.state, TaskState::Proposed, "{from} + {outcome:?}"); + } + } + } + + /// `parked` is deliberately outside the widening, and so is every state + /// past triage — a refine rewrites a brief, and by `running` the brief + /// is already being worked. + #[test] + fn refine_is_refused_everywhere_but_proposed_and_ready() { + for state in TaskState::ALL { + if matches!(state, TaskState::Proposed | TaskState::Ready) { + continue; + } + let (mut s, p) = store_with_project(); + let id = task_in_state(&mut s, p, state); + let result = s.apply(id, Action::Refine("thin body".into())); + assert!( + matches!(result, Err(Error::InvalidTransition { .. })), + "refine from {state} should be refused" + ); + } + } + + /// The dispatch race is closed by the state rather than by a guard + /// (DESIGN.md §6): a `ready` task under refinement is not in the + /// scheduler's input at all, so no window can hand it out while its + /// body is being rewritten. + #[test] + fn a_refining_task_leaves_the_ready_work_queue() { + let (mut s, p) = store_with_project(); + s.set_weight(p, 3).unwrap(); + let id = create(&mut s, p, TaskState::Ready); + assert!(crate::scheduler::focus(&s.candidates().unwrap()).is_some()); + + s.record_refine_launch(id, "thin body", "claude", Some(1), None) + .unwrap(); + let candidates = s.candidates().unwrap(); + assert!( + !candidates.iter().any(|c| c.task.id == id), + "a refining task is not a scheduler candidate" + ); + assert!(crate::scheduler::focus(&candidates).is_none()); + } + } + // --- human-only tasks (DESIGN.md §3/§6): the shortened path --- mod human { diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 3d641a1..0529173 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -1409,20 +1409,22 @@ impl App { } } - /// 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. + /// Whether a task's body is still a brief rather than work under way — what + /// gates the refine keys. A `ready` task qualifies as much as a `proposed` + /// one: its verdict was issued against the body, so rewriting the body sends + /// it back through triage (DESIGN.md §6). + pub fn is_refinable(&self, task_id: i64) -> bool { + self.all.iter().any(|r| { + r.task.id == task_id && matches!(r.task.state, TaskState::Proposed | TaskState::Ready) + }) + } + + /// Refine the selected task (DESIGN.md §6). Refine is an event on a brief + /// 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 whose + /// body is no longer a brief awaiting work 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; @@ -1434,9 +1436,9 @@ impl App { )); return; } - if state != TaskState::Proposed { + if !matches!(state, TaskState::Proposed | TaskState::Ready) { self.status = Some(format!( - "task is {state} — refine works on a proposal awaiting triage" + "task is {state} — refine works on a proposal or a ready task" )); return; } @@ -1454,8 +1456,8 @@ impl App { /// 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 - /// for a verdict on the improved version. + /// place. The task leaves the queue for `refining` while the round runs and + /// comes back `proposed` for a verdict on the improved version. fn refine_with_note(&mut self, task_id: i64, note: &str) { match crate::dispatch::refine(&mut self.store, &self.dispatch_ctx, task_id, note) { Ok(summary) => { @@ -3151,11 +3153,30 @@ mod tests { 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. + /// A task triaged `ready` against a body the operator has since soured on + /// refines from the queue exactly as a proposal does (DESIGN.md §6). #[test] - fn the_queue_refine_keys_explain_themselves_on_a_non_proposal() { + fn refine_key_answers_on_a_ready_task_too() { let mut app = app_with(&[TaskState::Ready]); + let task_id = app.selected_task_id().expect("the ready row is selected"); + + 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 ready task should open the refine-note prompt"), + } + } + + /// Past `ready` the body is a brief already being worked, so 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_work_under_way() { + let mut app = app_with(&[TaskState::Review]); key(&mut app, KeyCode::Char('r')); assert!(matches!(app.mode, Mode::Normal)); diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 71d2a82..de3192e 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -203,16 +203,19 @@ transitions the three verdicts move the proposal; refine is not a verdict — it dispatches an agent to rewrite the body against --note TEXT (or - --note-file PATH), moving the task proposed → - refining until the round concludes. It returns - to proposed marked ↻ refined for a re-triage - of the improved version, or ⚠ refine failed if - the agent died having written nothing. A - verdict on a refining task is refused: it is - out of the queue while the rewrite is in - flight. The note-less interactive variant is a - conversation with the agent, so it lives in - the TUI, on `R` over a proposal + --note-file PATH), moving the task proposed or + ready → refining until the round concludes. It + returns to proposed marked ↻ refined for a + re-triage of the improved version, or ⚠ refine + failed if the agent died having written + nothing. A task refined out of ready comes back + through triage because the verdict it carried + was issued against the replaced body. A verdict + on a refining task is refused: it is out of the + queue while the rewrite is in flight. The + note-less interactive variant is a conversation + with the agent, so it lives in the TUI, on `R` + over the row start ready → running ask --question TEXT running → needs-input resume needs-input → running, once you have answered @@ -2202,11 +2205,12 @@ fn ask_verb(store: &mut Store, args: AskArgs) -> Result { /// Triage a proposal (DESIGN.md §6). Three of the four outcomes are verdicts /// that transition the task; `refine` is the fourth — it dispatches an agent to -/// rewrite the body against the operator's note and leaves the task `proposed`, -/// so the improved version comes back round for a real verdict. A note is -/// required here: the note-less interactive variant is an agent conversation, -/// which is TUI-only for the same reason planning sessions are (§8) — the CLI is -/// how an LLM drives Voro. +/// rewrite the body against the operator's note, so the improved version comes +/// back round for a real verdict. The verdicts act on a proposal alone; refine +/// also accepts a `ready` id, whose rewritten body then returns through triage. +/// A note is required here: the note-less interactive variant is an agent +/// conversation, which is TUI-only for the same reason planning sessions are +/// (§8) — the CLI is how an LLM drives Voro. fn triage_verb(store: &mut Store, args: TriageArgs, ctx: &DispatchCtx) -> Result { let note = text_or_file(args.note, args.note_file)?; match Triage::try_from(args.target) { diff --git a/crates/voro/src/dispatch.rs b/crates/voro/src/dispatch.rs index ea8e7b1..ce6e793 100644 --- a/crates/voro/src/dispatch.rs +++ b/crates/voro/src/dispatch.rs @@ -511,10 +511,11 @@ pub fn plan_session( } /// The precondition both refine flavours share: a refine round starts from -/// `proposed` (DESIGN.md §6), so anything else is refused before a prompt is -/// written or a process spawned. The transition API refuses it again when the -/// round is recorded; this is the early, spelled-out refusal — including of a -/// task already `refining`, whose round the operator can only cancel. +/// `proposed` or `ready` (DESIGN.md §6), so anything else is refused before a +/// prompt is written or a process spawned. The transition API refuses it again +/// when the round is recorded; this is the early, spelled-out refusal — +/// including of a task already `refining`, whose round the operator can only +/// cancel. fn guard_refinable(store: &Store, task_id: i64) -> Result { let task = store.task(task_id).map_err(|e| e.to_string())?; if task.state == TaskState::Refining { @@ -522,9 +523,9 @@ fn guard_refinable(store: &Store, task_id: i64) -> Result refine-prompt.txt"); + let id = proposal(&mut store, &project, false); + store + .apply(id, voro_core::Action::Triage(voro_core::Triage::Ready)) + .unwrap(); + + refine(&mut store, &ctx, id, "name the files it touches").unwrap(); + assert_eq!(store.task(id).unwrap().state, TaskState::Refining); + assert!( + !store.candidates().unwrap().iter().any(|c| c.task.id == id), + "a refining task is out of the scheduler's input" + ); + + store + .conclude_refine(id, voro_core::RefineOutcome::Applied) + .unwrap(); + assert_eq!(store.task(id).unwrap().state, TaskState::Proposed); + assert!(store.refined_flag(id).unwrap()); + } + #[test] fn interactive_refine_seeds_the_plan_session_with_the_task() { let (mut store, ctx, project) = fixture_toml( @@ -2481,18 +2510,18 @@ mod tests { } #[test] - fn interactive_refine_is_refused_off_proposed() { + fn interactive_refine_is_refused_off_the_brief_states() { let (mut store, ctx, project) = fixture_toml( "default_agent = \"stub\"\n\n[agents.stub]\n\ dispatch = \"cat {prompt_file}\"\nplan = \"stub --interactive {prompt_file}\"\n", ); let id = proposal(&mut store, &project, false); store - .apply(id, voro_core::Action::Triage(voro_core::Triage::Ready)) + .apply(id, voro_core::Action::Triage(voro_core::Triage::Parked)) .unwrap(); let err = plan_session(&store, &ctx, PlanTarget::Refine { task_id: id }).unwrap_err(); - assert!(err.contains("proposed"), "{err}"); + assert!(err.contains("proposed or ready"), "{err}"); } /// A round already in flight is not refinable either — the second launch is diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 578cd43..621fbb0 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1641,9 +1641,11 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Line::from(spans), area); } -/// 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)) +/// Whether the selection is a brief refine can still rewrite — a proposal or a +/// ready task (DESIGN.md §6). +fn selection_is_refinable(app: &App) -> bool { + app.selected_task_id() + .is_some_and(|id| app.is_refinable(id)) } /// Every slot the current screen's key line can hold, each flagged with whether @@ -1660,7 +1662,7 @@ fn hint_candidates(app: &App) -> Vec<(&'static str, &'static str, bool)> { Screen::Cockpit => vec![ enter, ("d/D", "dispatch", selected), - ("r/R", "refine", selection_is_proposed(app)), + ("r/R", "refine", selection_is_refinable(app)), ("C", "cancel refine", app.selected_is_refining()), ("s", "state", true), ("!", "deep", selected), @@ -1674,7 +1676,7 @@ fn hint_candidates(app: &App) -> Vec<(&'static str, &'static str, bool)> { Screen::Tasks => vec![ enter, ("w", "wait", app.selected_can_hand_off()), - ("r/R", "refine", selection_is_proposed(app)), + ("r/R", "refine", selection_is_refinable(app)), ("C", "cancel refine", app.selected_is_refining()), ("s", "state", true), ("!", "deep", true), @@ -1721,7 +1723,8 @@ fn hint_candidates(app: &App) -> Vec<(&'static str, &'static str, bool)> { /// left to `?`. The line carries what changes a task's state or destiny; /// navigation, display toggles and browsing conveniences live in the key map /// only, so `?` is always present. Selection-only actions drop out when there -/// is nothing to act on, and the refine keys appear only on a proposal. +/// is nothing to act on, and the refine keys appear only on a task whose body is +/// still a brief — a proposal or a ready task. fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { hint_candidates(app) .into_iter() @@ -1739,8 +1742,8 @@ const DISPATCH_KEYS: [(&str, &str); 2] = [ ("D", "dispatch, choosing the agent first"), ]; const REFINE_KEYS: [(&str, &str); 2] = [ - ("r", "refine a proposal, leaving a note"), - ("R", "refine a proposal, talking to an agent"), + ("r", "refine a brief, leaving a note"), + ("R", "refine a brief, talking to an agent"), ]; const NEW_KEYS: [(&str, &str); 2] = [ ("n", "new task, written in $EDITOR"), @@ -3524,7 +3527,7 @@ mod tests { "fold the score decomposition", "dispatch, choosing the agent first", "new task, planned with an agent", - "refine a proposal, talking to an agent", + "refine a brief, talking to an agent", // The right-hand column, whole — nothing clipped at 80 columns. "page the card", "next screen", diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8c7617d..86a3fd3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -188,9 +188,9 @@ review = 1.4 | State | Meaning | Enters by | Leaves by | |---|---|---|---| | `proposed` | Suggested (often by an agent post-task); not yet triaged. In the queue for triage; never dispatched. | agent proposal, quick capture; a refine round concluding | human triage → `ready`/`parked`, or → `rejected`; *refine* (below) → `refining` | -| `refining` | An agent is rewriting this proposal's body right now (below). Out of the queue — the body a triage verdict would judge does not exist yet — and on the running strip instead. | *refine*, either flavour | the rewritten body lands, the agent dies, the session is quit, or the operator cancels → `proposed` | +| `refining` | An agent is rewriting this task's body right now (below). Out of the queue — the body a triage verdict would judge, or a dispatch would hand an agent, does not exist yet — and on the running strip instead. | *refine*, either flavour, from `proposed` or from `ready` | the rewritten body lands, the agent dies, the session is quit, or the operator cancels → `proposed`, whichever state the round began in | | `parked` | Triaged, real, but out of the running: dependencies open or deliberately deferred. Invisible to scheduler. | triage; dependency added | dependencies close → `ready`; manual unpark → `ready`; abandon → `rejected` | -| `ready` | Actionable now. Eligible for the queue's start rows and for `voro next`. | triage; last blocker closes | dispatch or manual start → `running`; park → `parked`; abandon → `rejected` | +| `ready` | Actionable now. Eligible for the queue's start rows and for `voro next`. | triage; last blocker closes | dispatch or manual start → `running`; *refine* (below) → `refining`; park → `parked`; abandon → `rejected` | | `running` | An agent (or the human) is actively on it. | dispatch | agent raises question → `needs-input`; work lands → `review`; abort → `ready`; session dies without reporting → `stalled` (reconcile, §8). | | `needs-input` | Blocked on a human decision; `question` is set. **First among equals in the queue.** | agent verb; manual flag | question answered in the agent's own session, then `resume` → `running` (no answer text is recorded — the exchange lives in the session transcript; §8); abandon → `rejected` | | `review` | Agent believes it is done; awaiting human acceptance. | agent completion | accept → `done`; reject with feedback → `running`; hand off → `waiting`; abandon → `rejected` | @@ -200,13 +200,15 @@ review = 1.4 Three deliberate choices. Any triaged, non-terminal state can be *abandoned* straight to `rejected` — obsolete work must not need walking through the rest of the machine to close, and parking (`ready` → `parked`) has a manual inverse for the same reason. Second, `needs-input`, `review`, and `stalled` are all human-attention states but are kept distinct because they sort differently: at equal score an unanswered question outranks a completed diff, which outranks a dead dispatch, which outranks startable work, which outranks an untriaged proposal — a question stalls in-flight work; a proposal's priority is agent-asserted and untrusted until triage, so it wins nothing but ties it deserves. Third, `proposed` exists precisely so agent-generated tasks can be captured freely without granting them anything: each proposed task competes in the queue on the same score as everything else and cannot be dispatched until a human triages it. Surfacing proposals in the queue rather than behind an approval step keeps the generation pipeline honest without automating it — triage is one keypress away. Under the queue's uniform cap (§7) a low-scoring proposal can fall past the visible rows into the browser, so an always-visible untriaged count is what keeps the pipeline felt when the individual rows drop off. -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. A refine in flight is a **state**, `refining`, and not merely an event on a `proposed` one. The distinction is the difference between a rewrite the store knows about and one only the launching window does: while the agent works, the proposal sits in the triage queue advertising a body that is about to be replaced, and any window — the operator routinely keeps a second instance open on the same database — can hand down a verdict racing the agent's own `voro set --body-file`. Putting the fact in `tasks.state` closes that by construction rather than by a guard: the task leaves the queue in every window at once (the scheduler's next-action query simply does not list `refining`), and a triage verdict from there is an illegal transition the store refuses, with no guard code anywhere above it. The operator's note rides the `proposed → refining` transition as a `refined` event, exactly as a completion summary rides `done` (§8). Nothing about the score changes — `refining` is unscored because it is not in the queue at all — and the improved version comes back round for a real verdict on the next pass. +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. A refine in flight is a **state**, `refining`, and not merely an event on a `proposed` one. The distinction is the difference between a rewrite the store knows about and one only the launching window does: while the agent works, the proposal sits in the triage queue advertising a body that is about to be replaced, and any window — the operator routinely keeps a second instance open on the same database — can hand down a verdict racing the agent's own `voro set --body-file`. Putting the fact in `tasks.state` closes that by construction rather than by a guard: the task leaves the queue in every window at once (the scheduler's next-action query simply does not list `refining`), and a triage verdict from there is an illegal transition the store refuses, with no guard code anywhere above it. The operator's note rides the transition into `refining` as a `refined` event, exactly as a completion summary rides `done` (§8). Nothing about the score changes — `refining` is unscored because it is not in the queue at all — and the improved version comes back round for a real verdict on the next pass. + +A round starts from `ready` as readily as from `proposed`, because a thin body is as often noticed *after* triage as before it: the operator waves a proposal through on its title, dispatches nothing, and reads the brief properly a week later. Without this the only recourse is the manual `set --body-file` that refine exists to spare them. The state earns its keep here exactly as it does before triage, one race along: a `ready` task under refinement leaves the scheduler's input entirely, so neither `voro next` nor a second window's dispatch can hand an agent the body that is being replaced. Where the two origins might have parted company is the landing, and deliberately they do not — a round concludes to `proposed` however it began, and remembers nothing of where it started. The `ready` verdict was passed on a body that no longer exists, so it is not a verdict on what the task now says; returning the rewrite through triage costs one keypress and re-asks the question the rewrite has just reopened. `parked` stays out of this, not because refining a deferred brief is incoherent but because nothing has yet wanted it. A round ends by returning to `proposed`, and *how* it ended is what the returned row says. Four triggers, all landing on the one transition. The rewritten body arriving is the first: a `voro set` carrying `--body`/`--body-file` on a `refining` task concludes the round, which needs no new agent obligation because the refine prompts already end in exactly that verb. A dead agent is the second, caught by the same reconcile-on-read that catches a dead dispatch (§8). The third is quitting an interactive session without concluding anything, which is a no-op rather than a failure. The fourth is the operator cancelling from the running strip, the escape hatch for an agent that is *hung* — still alive, so reconcile will never catch it — which kills the process as well as moving the state. The first marks the returned proposal `↻ refined` in the queue, task browser, `list`, and `show` until triage takes it out of `proposed`, so the operator can see which rows have moved since they last read them; the second marks it `⚠ refine failed`, and it must be a marker of its own rather than the absence of the first, since the operator would otherwise have to notice that a rewrite they asked for silently never happened. The third and fourth leave no marker, having changed nothing. In the queue, where proposals collapse into a per-project digest (§7), the constituent rows carry their markers once the digest is folded open and the digest itself carries the counts — `↻ 2 refined` — since a collapsed digest would otherwise hide the very fact that its bodies have improved. The markers are derived from the round that just concluded rather than from any `refined` event ever recorded, which is what makes the promise honest: what `↻ refined` says is that this body *is* the rewritten one, not merely that a rewrite was once asked for. 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. It opens a `sessions` row like a dispatch — the pid is what reconcile probes, the log is where the launcher's banner lands, and the strip reads both — which costs nothing against the one-open-session invariant (§8): a proposal has no other open session, and by the time it can be dispatched the refine round has concluded and closed its own. The session is *named* as well — `voro--refine` (§8) — so the operator can find it in the agent's own fleet listing and attach to it, which matters precisely because the launcher exits at birth and the log holds its banner rather than the rewrite. -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. It opens a session row like the headless flavour, recorded once the foreground child's pid is known so a Voro that dies mid-conversation leaves a round another window's reconcile can still finish; on return the round concludes as applied if the agent's own `set --body-file` already ended it, and as cancelled otherwise. 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 refined proposal comes back for a verdict rather than having received one, so putting refine there filed 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 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. +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. It opens a session row like the headless flavour, recorded once the foreground child's pid is known so a Voro that dies mid-conversation leaves a round another window's reconcile can still finish; on return the round concludes as applied if the agent's own `set --body-file` already ended it, and as cancelled otherwise. 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 row whose body is still a brief, proposal or `ready` alike — `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 refined proposal comes back for a verdict rather than having received one, so putting refine there filed 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 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` or `ready` 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. Its identity comes from the same `Launch` value every launch uses (§8), so the next instance inherits a session name, a prompt/log file slug and a launch-log label by adding a variant rather than computing each of them again.