From d838df16f9961b95f2ef145d2e559e8fb978d63e Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 1 Aug 2026 18:47:31 +0100 Subject: [PATCH] Keep the body a task edit replaced, and refuse to blank one by accident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `voro set --body-file` swapped a task's whole brief for whatever the file held, in place, with nothing left behind — the one field an edit destroys outright and the one whose loss cannot be reconstructed from state elsewhere. Working mote #286 an agent ran `set 284 --body-file /dev/null` meaning a no-op and wiped #284's body; the events table stores only kind/detail transitions, so the text was unrecoverable and had to be rewritten by hand from the parent task's context. Every edit that changes a non-empty body now records the text it replaced as a `body` event on the append-only log. It rides `Store::update_task`, so it covers all three writers — the CLI flag, a refine agent, and the TUI editor — and only fires on a real change, since every `set` passes through there. That detail is bulk kept for recovery rather than reading, so `show` and the TUI history fold it to a marker naming the event that holds it (`replaced body kept (37 lines) — voro show 62 --event 512`) instead of unrolling a superseded brief into the log, and `voro show --event ` prints one event's detail alone and undecorated, so recovery is a redirect back through `set --body-file` rather than a verb of its own. A replacement that would leave a non-empty body empty is refused unless `--allow-empty` says so: nothing legitimate reads as "blank the brief", and the way one actually arrives is a slip. Emptying an already-empty body destroys nothing and passes unremarked. Beside the replacing pair sits an additive one, `--append-body`/`--append-body-file`, which adds after a blank line — the "record a finding on the task" case that was what the agent in the incident was actually trying to do. DESIGN.md §8 gains the paragraph, beside the summary's, and the skill's CLI reference names both new flags. --- CHANGELOG.md | 6 + crates/voro-core/src/store.rs | 61 ++++++++- crates/voro/src/cli.rs | 180 +++++++++++++++++++++++--- crates/voro/src/ui.rs | 41 +++++- docs/DESIGN.md | 4 +- plugins/voro/skills/voro-cli/SKILL.md | 9 ++ 6 files changed, 283 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bab0419..d0e9bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 A pair of tasks carrying two edges keeps the one not named, so an edge authored by mistake no longer has to be removed with raw SQL; dropping a blocker reconciles readiness as any other blocker edit does. +- **Body edits are recoverable and guarded.** Every edit that changes a + non-empty task body records the text it replaced as a `body` event, so a + rewrite is no longer irreversible; `voro show --event ` prints + that text back, ready to redirect into `set --body-file`. A replacement that + would leave the body empty is refused unless `--allow-empty` is given, and + `voro set --append-body[-file]` adds to a body instead of replacing it. - **Document links**: register the plan or design doc a body of work derives from with `voro doc add `, and link it to the tasks it spawned (`voro doc link/unlink`, or `--doc` on `voro add`/`voro set`). `voro diff --git a/crates/voro-core/src/store.rs b/crates/voro-core/src/store.rs index 894541b..870532a 100644 --- a/crates/voro-core/src/store.rs +++ b/crates/voro-core/src/store.rs @@ -867,7 +867,8 @@ impl Store { }); } } - self.conn.execute( + let tx = self.conn.transaction()?; + tx.execute( "UPDATE tasks SET title = ?1, body = ?2, priority = ?3, agent = ?4, human = ?5, deep = ?6 WHERE id = ?7", @@ -881,6 +882,14 @@ impl Store { id ], )?; + // A body edit overwrites the task's whole brief in place, so the log + // keeps the text it replaced (DESIGN.md §8) — the append-only audit + // covering the one field whose loss cannot be reconstructed from state. + // Only a real change is logged, since every `set` lands here. + if edit.body != current.body && !current.body.is_empty() { + log_event(&tx, id, "body", Some(¤t.body))?; + } + tx.commit()?; self.task(id) } @@ -1774,6 +1783,56 @@ mod tests { assert!(human.human); } + /// The body is the one field an edit overwrites wholesale, so the log keeps + /// what each edit replaced (DESIGN.md §8) — and only that, since every `set` + /// passes through here whether or not it touched the body. + #[test] + fn update_task_logs_the_body_it_replaced_and_nothing_else() { + let (mut s, p) = human_fixture(); + let task = s.create_task(new_with(p, None, false)).unwrap(); + + // an empty body destroys nothing on its way out + let write = TaskEdit { + body: "the brief".into(), + ..edit_of(&task, None, false) + }; + let task = s.update_task(task.id, write).unwrap(); + assert!( + !s.events_for(task.id) + .unwrap() + .iter() + .any(|e| e.kind == "body") + ); + + // an edit that leaves the body alone logs nothing either + let retitle = TaskEdit { + title: "renamed".into(), + ..edit_of(&task, None, false) + }; + let task = s.update_task(task.id, retitle).unwrap(); + assert!( + !s.events_for(task.id) + .unwrap() + .iter() + .any(|e| e.kind == "body") + ); + + let rewrite = TaskEdit { + body: "a rewrite".into(), + ..edit_of(&task, None, false) + }; + let task = s.update_task(task.id, rewrite).unwrap(); + assert_eq!(task.body, "a rewrite"); + let logged: Vec = s + .events_for(task.id) + .unwrap() + .into_iter() + .filter(|e| e.kind == "body") + .map(|e| e.detail.unwrap_or_default()) + .collect(); + assert_eq!(logged, vec!["the brief".to_string()]); + } + #[test] fn update_task_guards_the_agent_human_exclusivity_both_ways() { let (mut s, p) = human_fixture(); diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index c31e496..74697ac 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -9,7 +9,7 @@ use std::fmt::Write as _; use clap::{Args, Parser, Subcommand, ValueEnum}; use voro_core::{ - Action, AgentsConfig, DepKind, Doc, NewTask, PrRef, Priority, Project, QueueRow, Repo, + Action, AgentsConfig, DepKind, Doc, Event, NewTask, PrRef, Priority, Project, QueueRow, Repo, ReviewAction, ReviewMedium, Store, Task, TaskEdit, TaskState, Triage, WipGate, scheduler, }; @@ -100,11 +100,18 @@ tasks discovered-from that task (dispatch renders the flag with the running task's id) set [--title T] [--priority 0-3] [--agent NAME | --no-agent] - [--body TEXT | --body-file PATH] [--blocked-by IDS] [--blocks IDS] - [--unlink KIND:ID] [--pr URL | --no-pr] [--branch NAME | --no-branch] + [--body TEXT | --body-file PATH] [--append-body TEXT | --append-body-file PATH] + [--allow-empty] [--blocked-by IDS] [--blocks IDS] [--unlink KIND:ID] + [--pr URL | --no-pr] [--branch NAME | --no-branch] [--human | --no-human] [--deep | --no-deep] [--summary TEXT | --summary-file PATH] [--repo NAME | --no-repo] [--doc DOCS | --no-doc] + --body replaces the whole body; --append-body + adds to what is there, after a blank line. + A replacement that would leave the body empty + is refused unless --allow-empty; either way + the replaced text is kept on the event log + (`show` names the event to recover it from) --blocked-by replaces this task's own blocker list; --blocks adds this task as a blocker of each listed task @@ -128,7 +135,11 @@ tasks --doc replaces the task's whole document list (`voro doc link` adds one without listing the rest); --no-doc clears it - show full task: body, docs, deps, events + show [--event EVENT-ID] + full task: body, docs, deps, events. --event + prints one event's recorded detail and nothing + else, which is how a replaced body comes back: + `voro show 62 --event 512 > body.md` list [--state STATE] [--project P] [--doc DOC] --doc answers 'which tasks derive from this plan?' @@ -261,6 +272,8 @@ enum Verb { Set(SetArgs), Show { task_id: i64, + #[arg(long, value_name = "EVENT-ID")] + event: Option, }, List(ListArgs), Inbox, @@ -480,6 +493,12 @@ struct SetArgs { body: Option, #[arg(long, conflicts_with = "body")] body_file: Option, + #[arg(long, conflicts_with_all = ["body", "body_file"])] + append_body: Option, + #[arg(long, conflicts_with_all = ["body", "body_file", "append_body"])] + append_body_file: Option, + #[arg(long)] + allow_empty: bool, #[arg(long)] blocked_by: Option, #[arg(long)] @@ -624,7 +643,10 @@ pub fn run(store: &mut Store, args: Vec, ctx: &DispatchCtx) -> Result add_verb(store, args), Verb::Propose(args) => propose_verb(store, args), Verb::Set(args) => set_verb(store, args), - Verb::Show { task_id } => show_verb(store, task_id), + Verb::Show { task_id, event } => match event { + Some(event_id) => show_event(store, task_id, event_id), + None => show_verb(store, task_id), + }, Verb::List(args) => list_verb(store, &args), Verb::Inbox => inbox_verb(store, ctx), Verb::Next => next_verb(store), @@ -759,6 +781,59 @@ fn text_or_file(text: Option, path: Option) -> Result Result { + if let Some(added) = text_or_file(args.append_body.take(), args.append_body_file.take())? { + return Ok(appended_body(current, &added)); + } + let Some(replacement) = text_or_file(args.body.take(), args.body_file.take())? else { + return Ok(current.to_string()); + }; + if replacement.trim().is_empty() && !current.trim().is_empty() && !args.allow_empty { + return Err(format!( + "refusing to empty the body of task {id} ({} lines) — pass --allow-empty if you mean it", + current.lines().count() + )); + } + Ok(replacement) +} + +/// An addition to an existing body, separated from it by one blank line. An +/// empty body takes the addition as-is, so the first append reads like a write. +fn appended_body(current: &str, added: &str) -> String { + let base = current.trim_end(); + if base.is_empty() { + return added.to_string(); + } + format!("{base}\n\n{}", added.trim_start_matches('\n')) +} + +/// How an event's recorded detail reads in a history listing. Everything is its +/// own detail except a `body` event, whose detail is the whole text a body edit +/// replaced (DESIGN.md §8) — bulk kept for recovery, not for reading, so the +/// line says what is there and how to get it back instead of unrolling it. +pub(crate) fn event_detail(event: &Event) -> String { + let detail = event.detail.clone().unwrap_or_default(); + if event.kind != "body" { + return detail; + } + let lines = detail.lines().count(); + match event.task_id { + Some(task_id) => format!( + "replaced body kept ({lines} lines) — voro show {task_id} --event {}", + event.id + ), + None => format!("replaced body kept ({lines} lines)"), + } +} + /// Free-text positionals (a title, an answer, rejection feedback) arrive as /// the words the shell split them into; join them back and refuse emptiness. fn joined(words: &[String], what: &str) -> Result { @@ -1338,9 +1413,10 @@ fn propose_verb(store: &mut Store, args: ProposeArgs) -> Result Ok(out) } -fn set_verb(store: &mut Store, args: SetArgs) -> Result { +fn set_verb(store: &mut Store, mut args: SetArgs) -> Result { let id = args.task_id; let current = store.task(id).map_err(|e| e.to_string())?; + let body = set_body(¤t.body, &mut args, id)?; let agent = if args.no_agent { None } else { @@ -1358,7 +1434,7 @@ fn set_verb(store: &mut Store, args: SetArgs) -> Result { }; let edit = TaskEdit { title: args.title.unwrap_or(current.title), - body: text_or_file(args.body, args.body_file)?.unwrap_or(current.body), + body, priority: args.priority.unwrap_or(current.priority), agent, human, @@ -1661,18 +1737,24 @@ fn show_verb(store: &mut Store, id: i64) -> Result { } writeln!(out, "\nevents:").unwrap(); for e in store.events_for(id).map_err(|e| e.to_string())? { - writeln!( - out, - " {} {} {}", - e.at, - e.kind, - e.detail.unwrap_or_default() - ) - .unwrap(); + writeln!(out, " {} {} {}", e.at, e.kind, event_detail(&e)).unwrap(); } Ok(out) } +/// One event's recorded detail, alone and undecorated, so it can be redirected +/// straight into a file: `voro show 62 --event 512 > body.md` is how the body a +/// `set` replaced comes back (DESIGN.md §8). The event must belong to the task +/// named, so a mistyped id reads as an error rather than another task's text. +fn show_event(store: &mut Store, task_id: i64, event_id: i64) -> Result { + let events = store.events_for(task_id).map_err(|e| e.to_string())?; + let event = events + .iter() + .find(|e| e.id == event_id) + .ok_or_else(|| format!("task {task_id} has no event {event_id}"))?; + Ok(event.detail.clone().unwrap_or_default()) +} + fn list_verb(store: &mut Store, args: &ListArgs) -> Result { let state_filter = match &args.state { Some(raw) => Some(TaskState::parse(raw).map_err(|e| e.to_string())?), @@ -2602,6 +2684,74 @@ mod tests { assert!(s.refined_flag(1).unwrap()); } + /// A body replacement is destructive, so the two guards of DESIGN.md §8 + /// hold: one that would leave the body empty is refused outright, and the + /// text any accepted replacement discards is recoverable from the log. + #[test] + fn emptying_a_body_is_refused_and_a_replaced_one_is_recoverable() { + let mut s = store(); + ok(&mut s, &["project", "add", "demo", "/tmp"]); + ok( + &mut s, + &["add", "demo", "An idea", "--body", "the brief\nline two"], + ); + + let e = err(&mut s, &["set", "1", "--body", ""]); + assert!(e.contains("--allow-empty"), "{e}"); + assert_eq!(s.task(1).unwrap().body, "the brief\nline two"); + + ok(&mut s, &["set", "1", "--body", "a rewrite"]); + assert_eq!(s.task(1).unwrap().body, "a rewrite"); + + // The history says a body was replaced and names the event to get it + // back, rather than unrolling the old text into the listing. + let out = ok(&mut s, &["show", "1"]); + assert!(out.contains("replaced body kept (2 lines)"), "{out}"); + assert!(!out.contains("line two"), "{out}"); + + let event = out + .lines() + .find(|l| l.contains("--event")) + .and_then(|l| l.rsplit(' ').next().map(str::to_string)) + .expect("the body event names its own id"); + assert_eq!( + ok(&mut s, &["show", "1", "--event", &event]), + "the brief\nline two" + ); + assert!(err(&mut s, &["show", "1", "--event", "999"]).contains("no event 999")); + + // Emptying it is allowed once said explicitly — and still recoverable. + ok(&mut s, &["set", "1", "--body", "", "--allow-empty"]); + assert_eq!(s.task(1).unwrap().body, ""); + } + + /// `--append-body-file` is the additive spelling for the common "record a + /// finding on the task" case, which otherwise gets written as a replacement + /// and takes the brief with it (DESIGN.md §8). + #[test] + fn append_body_adds_to_the_brief_instead_of_replacing_it() { + let mut s = store(); + ok(&mut s, &["project", "add", "demo", "/tmp"]); + ok(&mut s, &["add", "demo", "An idea", "--body", "the brief\n"]); + + let path = std::env::temp_dir().join(format!("voro-append-{}.md", std::process::id())); + std::fs::write(&path, "a finding\n").unwrap(); + ok( + &mut s, + &["set", "1", "--append-body-file", path.to_str().unwrap()], + ); + std::fs::remove_file(&path).unwrap(); + assert_eq!(s.task(1).unwrap().body, "the brief\n\na finding\n"); + + ok(&mut s, &["set", "1", "--append-body", "another"]); + assert_eq!(s.task(1).unwrap().body, "the brief\n\na finding\n\nanother"); + + // Replacement and addition are different intents, not two spellings of + // one, so asking for both at once is a parse error rather than a race. + let e = err(&mut s, &["set", "1", "--body", "x", "--append-body", "y"]); + assert!(e.contains("cannot be used with"), "{e}"); + } + /// The inbox renders each row's next-action verb in place of the state, /// mirroring the TUI queue — both from `Task::next_action()` (DESIGN.md §3). #[test] diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 5b5af3d..e3dae6f 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -462,7 +462,7 @@ fn history_lines(events: &[Event]) -> Vec> { Line::from(vec![ Span::styled(format!("{:<19} ", e.at), Style::new().dim()), Span::styled(format!("{:<10} ", e.kind), Style::new().bold()), - Span::raw(e.detail.clone().unwrap_or_default()), + Span::raw(crate::cli::event_detail(e)), ]) })); } @@ -2401,6 +2401,45 @@ mod tests { assert_eq!(app.detail_scroll, 0, "a new selection starts at the top"); } + /// A `body` event's detail is a whole replaced brief (DESIGN.md §8), so the + /// history folds it to a marker naming the event that holds it rather than + /// spilling a superseded body across the pane. Every other kind reads as-is. + #[test] + fn history_folds_a_replaced_body_to_a_recovery_marker() { + let events = vec![ + Event { + id: 4, + task_id: Some(62), + at: "2026-08-01 10:00:00".into(), + kind: "priority".into(), + detail: Some("P1".into()), + }, + Event { + id: 5, + task_id: Some(62), + at: "2026-08-01 10:01:00".into(), + kind: "body".into(), + detail: Some("the brief\nline two\nline three".into()), + }, + ]; + let rendered: Vec = history_lines(&events) + .iter() + .map(ratatui::text::Line::to_string) + .collect(); + assert!( + rendered + .iter() + .any(|l| l.contains("priority") && l.contains("P1")) + ); + let body = rendered + .iter() + .find(|l| l.contains("body")) + .expect("the body event renders"); + assert!(body.contains("replaced body kept (3 lines)"), "{body}"); + assert!(body.contains("voro show 62 --event 5"), "{body}"); + assert!(!body.contains("line two"), "{body}"); + } + /// End-to-end: on the tasks screen the same sections fold into the Detail /// popup — `x`/`h` inside the popup drive the same shared flags — so score /// and history render inline on this screen too, never as separate popups. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 32d0c07..1a2acbc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -137,7 +137,7 @@ CREATE TABLE sessions ( outcome TEXT -- 'completed','asked','failed','capped','aborted' ); -CREATE TABLE events ( -- append-only audit of state transitions & answers +CREATE TABLE events ( -- append-only audit: transitions, answers, replaced bodies id INTEGER PRIMARY KEY, task_id INTEGER REFERENCES tasks(id), at TEXT NOT NULL, @@ -285,6 +285,8 @@ Naming the id literally is what makes the return path survive the launch style t `done`'s optional `--summary` (or `--summary-file`, for a multi-line one) is the agent's own account of what it did: it rides the `running → review` transition and is recorded as a `summary` event on the append-only log — not written into the task body, which stays the human's brief — so the review queue and detail view open on it rather than a bare state change, and `pr` opens the pull request straight from it (below), which is why it is written to read as a PR description. It stays optional throughout — a planning or task-generation task produces no code and no summary — so `done` *warns* rather than fails when a task reaches `review` without a branch or summary. That warning only reaches the caller's stdout — the agent's own log — so the durable surface is an **incomplete-report flag**: a `review` task carrying *exactly one* of a branch and a summary is rendered with an `[incomplete report]` marker in the queue, task browser, detail pane, and `show`/`list`, read fresh from task and event state, never stored. The XOR is deliberate — *neither* half is a legitimate no-artifact task (planning, triage), *both* is a complete report, and one without the other is a done report a dispatched session left unfinished. The flag names the report rather than promising a `pr` failure because the anomaly holds on *every* review medium (below): the summary is still what the review queue and reject-with-feedback read, and the branch still ties the task to its work, so it applies unchanged across media. Surfacing it is what makes the dispatch guarantee hold: every dispatched session ends either with a complete report or with a *visible* anomaly the operator can act on — the `stalled` state for a session that died without reporting (below), or this marker for one that reported only half. Because the branch is registered early (below), the flag most often catches a missing `--summary`. The summary is not write-once: `voro set --summary TEXT` (or `--summary-file PATH`) appends a fresh `summary` event, and because every reader takes the *newest*, the new account supersedes the old on the next read while the log keeps both — amending a thin summary before `pr`, or supplying the missing half of an `[incomplete report]` in place instead of churning through `reject` → re-`done`. It is allowed on a `running` task (a resumed agent recording its account before `done`) or a `review` one (fixing the report after), and it only ever writes the event, never `tasks.state`. +**The task body** is the mirror image of the summary, and needs the opposite treatment. A summary is superseded rather than overwritten — every reader takes the newest event and the log keeps them all — but `set --body-file` swaps the whole brief for whatever the file holds, in place, with nothing left behind: the one field an edit destroys outright and the one whose loss cannot be reconstructed from state elsewhere. Two things make that safe without making it ceremonial. First, every edit that changes a non-empty body records the text it replaced as a `body` event on the append-only log — whatever wrote it, a CLI flag, a refine agent (§6), or the TUI's editor, since all three pass through the single store call — so the log covers the body as it already covers transitions and summaries. That detail is bulk kept for recovery rather than for reading, so the history listings render it as a one-line marker naming the event that holds it (`replaced body kept (37 lines) — voro show 62 --event 512`) instead of unrolling a superseded brief into the log, and `voro show --event ` prints one event's detail alone and undecorated, so recovering a body is a redirect back through `set --body-file` rather than a verb of its own. Second, a replacement that would leave a non-empty body *empty* is refused unless `--allow-empty` says so. Nothing legitimate reads as "blank the brief", and the way one actually arrives is a slip — `--body-file /dev/null` typed as a no-op, an editor saved empty, a generated file that came out blank — which is exactly the case a guard can distinguish and an undo can only clean up after; emptying an already-empty body destroys nothing and passes unremarked. Beside the replacing pair sits an additive one, `--append-body`/`--append-body-file`, which adds to the existing body after a blank line: the "record a finding on the task" case that otherwise gets spelled as a replacement and takes the brief with it. + **Linked documents** (§3) ride the same preamble mechanism as branch names, and for the same reason: the dispatcher already owns the prompt file, so the plan a task derives from can be handed over rather than left to be rediscovered from hints in the body. A task carrying document links renders an extra block naming each one at its *resolved* location, ahead of the body separator so it is read before the task itself — absolute for a path, because a linked document may live in another project's checkout entirely and a location relative to the session's working directory would point at nothing. A task with no links renders no block at all, so an unlinked dispatch's prompt is byte-for-byte what it was before documents existed. Voro neither reads nor parses the document: it names it, exactly as it names a branch, and what the agent does with it is the agent's business. Registering a document stays a CLI affair (`doc add`/`remove`, plus `--doc` on `add` and `set`, where it replaces the whole list as `--blocked-by` does), but *linking* one does not: `c` on a selected task — on the cockpit, in the task browser, and inside the browser's detail popup — opens a picker over every registered document with the ones the task already cites ticked, and ⏎ links or unlinks the highlighted one in place through the same store calls `doc link`/`doc unlink` make, leaving the picker open so several can be toggled in one visit. Linking earns the key that registration does not because the moment a link most wants making is while triaging a proposal in the queue, which is exactly where the operator already is, whereas registration is a rarer and wordier act — a location, a title, sometimes a repo — with no such pull. The picker spans every project's documents rather than the task's own, since a task in any project may cite any plan (§3), with the owning project's name on the ones that are not the task's and the task's own listed first. That picker is the whole of the TUI's librarianship: there is no documents screen, and a document's own row — its title, its location, the tasks it backs — remains `doc list`/`doc show`, which keeps the cockpit about attention. **Branch names** flow through dispatch in both directions, and Voro runs no git in either — it only passes a name in and records one back. A task carries an optional `branch` (schema §5): the *intended* name a human sets with `voro set --branch`, which is the mechanism for attaching a task to an existing branch as much as for naming a fresh one. When set, dispatch renders it into the prompt preamble — telling the agent to create or check out that branch itself before working, since the agent knows the checkout's state better than the dispatcher and Voro deliberately never touches the working tree. Either way — whether a human named the branch or the agent chooses its own — the preamble tells the agent to register that branch with `voro set --branch NAME` the moment it creates or checks it out, so Voro records the real branch while the task is still `running` (letting reconcile, attach, `voro pr`, and the UI reflect it, and capturing it even if the agent never reaches a clean `done`) rather than only learning it at completion. The reverse direction is the *reported* name: `voro done --branch NAME` (and, belt-and-braces, the `SessionEnd` hook in [`agent-integration.md`](agent-integration.md)) records the branch the work actually landed on, overwriting any intended name — and re-confirms the early-registered name for the assigned case. The intended name is a suggestion the agent may follow or override; the reported name is the source of truth. Storing it on the task rather than the session means it survives redispatch and reads naturally beside `pr_url`, so a task correlates with its PR and its branch at a glance; Voro never reads the checkout's HEAD to infer it, consistent with the task-state-versus-session-state boundary above. diff --git a/plugins/voro/skills/voro-cli/SKILL.md b/plugins/voro/skills/voro-cli/SKILL.md index 034bcb1..f842dbb 100644 --- a/plugins/voro/skills/voro-cli/SKILL.md +++ b/plugins/voro/skills/voro-cli/SKILL.md @@ -28,6 +28,7 @@ voro inbox # the next-action queue: questions, reviews, proposals, voro next # the single top ready task, with its full body voro list [--state ready] [--project NAME] voro show # body, deps, event history +voro show --event # one event's detail alone, undecorated voro explain # score decomposition (weight × priority + age bonus) ``` @@ -37,6 +38,7 @@ voro explain # score decomposition (weight × priority + age bonus) voro add --body-file plan.md [--priority 0-3] [--blocked-by 3,7] [--blocks 9] [--repo NAME] [--deep] voro set <id> [--title T] [--priority N] [--body-file F] + [--append-body-file F] # add to the body instead of replacing it [--blocked-by IDS] [--blocks IDS] [--unlink KIND:ID] [--branch NAME] # intended git branch dispatch injects [--repo NAME | --no-repo] # which checkout the task runs in @@ -57,6 +59,13 @@ under that task, and leaves any other edge to the same task standing. Repeat the flag for several. Naming an edge that is not there fails rather than reporting a success it did not perform. +`--body-file` on `set` replaces the **whole** body, so reach for +`--append-body-file` when you mean to add a finding to a task rather than +rewrite its brief. A replacement that would leave the body empty is refused +unless you pass `--allow-empty`, and the text any replacement discards is kept +on the event log — `show` marks the `body` event with the command that prints +it back (`voro show <id> --event <event-id> > body.md`). + `add` defaults to state `proposed`, which is what an agent should almost always want: proposed tasks wait for human triage and never enter the queues untriaged. Write the body as a **dispatchable prompt** — self-contained