use crate::util::{fmt_duration, fmt_int, short_id};
use anyhow::{bail, Context, Result};
use cv_core::ir::truncate;
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_workflow(
id: &str,
run_id: Option<String>,
harness: Option<String>,
json: bool,
script: bool,
results: bool,
follow: bool,
revive: bool,
revive_all: bool,
) -> Result<()> {
let (want, id) = crate::util::split_harness_id(id, crate::util::parse_harness(&harness)?);
let found = match cv_core::find_cheap(id, want) {
Ok(found) => found,
Err(e) => return crate::util::resolve_found(Err(e), id, want).map(|_| ()),
};
let Some((r, _adapter)) = found else {
return workflow_by_name_fleetwide(id, want, json, script, results, revive, revive_all);
};
let Some(run_id) = run_id else {
return list_workflows(&r, json);
};
if follow {
return follow_workflow(&r, &run_id, json, script, results);
}
let mut wf = cv_core::workflow_of(&r, &run_id).with_context(|| {
let runs = cv_core::workflows_of(&r);
let names: Vec<&str> = runs.iter().filter_map(|w| w.name.as_deref()).collect();
format!(
"no workflow {run_id:?} in {} — {} run(s): {}",
short_id(&r.id),
runs.len(),
if names.is_empty() {
"(unnamed)".into()
} else {
names.join(", ")
},
)
})?;
wf.attach_journal(&r.path);
emit_workflow(&wf, json, script, results, revive, revive_all, Some(r.path.as_path()))
}
fn follow_workflow(r: &cv_core::SessionRef, key: &str, json: bool, script: bool, results: bool) -> Result<()> {
use std::collections::HashMap;
let mut seen: HashMap<u64, String> = HashMap::new(); let mut announced = false;
loop {
let Some(wf) = cv_core::workflow_of(r, key) else {
if !announced {
eprintln!("… waiting for run {key:?} to register (Ctrl-C to stop)");
announced = true;
}
std::thread::sleep(std::time::Duration::from_secs(2));
continue;
};
if !announced {
eprintln!(
"✦ following {} ({}) — Ctrl-C to stop",
wf.name.as_deref().unwrap_or("(unnamed)"),
wf.run_id,
);
announced = true;
}
let now = crate::util::fmt_local(chrono::Utc::now(), "%H:%M:%S");
for a in wf.phases.iter().flat_map(|p| &p.agents).chain(&wf.orphan_agents) {
let state = a.state.clone().unwrap_or_default();
if seen.get(&a.index).is_some_and(|s| *s == state) {
continue;
}
seen.insert(a.index, state.clone());
let glyph = match state.as_str() {
"done" => "✓",
"error" => "✗",
"progress" => "…",
_ => "▸",
};
let tok = a.tokens.map(|t| format!(" ({} tok)", fmt_int(t))).unwrap_or_default();
println!(
"{now} {glyph} {} → {state}{tok}",
a.label.as_deref().unwrap_or("(agent)")
);
}
let live = matches!(wf.status.as_deref(), None | Some("running") | Some("started"));
if !live {
println!("\n── run reached {} ──\n", wf.status.as_deref().unwrap_or("?"));
let mut wf = wf;
wf.attach_journal(&r.path);
return emit_workflow(&wf, json, script, results, false, false, Some(r.path.as_path()));
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
fn emit_workflow(
wf: &cv_core::Workflow,
json: bool,
script: bool,
results: bool,
revive: bool,
revive_all: bool,
session_path: Option<&std::path::Path>,
) -> Result<()> {
if revive {
return render_revive(wf, revive_all, session_path);
}
if json {
println!("{}", serde_json::to_string_pretty(wf)?);
return Ok(());
}
render_workflow(wf, script)?;
if results {
render_journal_results(wf);
} else if wf
.phases
.iter()
.flat_map(|p| &p.agents)
.chain(&wf.orphan_agents)
.any(|a| a.journal_result.is_some())
{
println!("→ `--results` for each agent's FULL journaled return (previews above are ~400 chars)");
}
Ok(())
}
fn render_journal_results(w: &cv_core::Workflow) {
let agents: Vec<&cv_core::WorkflowAgent> = w
.phases
.iter()
.flat_map(|p| &p.agents)
.chain(&w.orphan_agents)
.filter(|a| a.journal_result.is_some())
.collect();
if agents.is_empty() {
println!("\n(no journaled per-agent results — the run's journal is absent or empty)");
return;
}
println!("\n## full per-agent returns ({} journaled)", agents.len());
for a in agents {
println!(
"\n### {} {}",
a.label.as_deref().unwrap_or("(agent)"),
a.agent_id.as_deref().map(short_id).unwrap_or_default(),
);
let r = a.journal_result.as_ref().unwrap();
println!("{}", serde_json::to_string_pretty(r).unwrap_or_else(|_| r.to_string()));
}
}
fn workflow_by_name_fleetwide(
name: &str,
want: Option<cv_core::ir::Harness>,
json: bool,
script: bool,
results: bool,
revive: bool,
revive_all: bool,
) -> Result<()> {
let hits = cv_core::find_workflows_by_name(name);
if hits.is_empty() {
let ghosts = cv_core::find_ghost_launches_by_name(name);
if ghosts.is_empty() {
match cv_core::find(name, want) {
Ok(Some((r, _adapter))) => return list_workflows(&r, json),
Ok(None) => {}
Err(e) => return crate::util::resolve_found(Err(e), name, want).map(|_| ()),
}
}
if !ghosts.is_empty() {
println!(
"no recorded run named {name:?} — but {} GHOST launch(es) match (state never persisted; crash/kill before write?):\n",
ghosts.len()
);
for (r, g) in &ghosts {
println!(
" {} — session {} ({})",
ghost_line(g),
short_id(&r.id),
r.title.as_deref().unwrap_or("untitled"),
);
}
println!("\n→ debris dirs live under the session dir's subagents/workflows/<runId>/ (`cv show <agent-id>` opens a transcript)");
return Ok(());
}
bail!("no session and no workflow name matching {name:?} (try `cv ls -q 'workflow:{name}'`)");
}
if hits.len() == 1 {
let (r, wf) = &hits[0];
let mut wf = wf.clone();
wf.attach_journal(&r.path);
if !json {
println!(
"(in session {} — {})\n",
short_id(&r.id),
r.title.as_deref().unwrap_or("untitled")
);
}
return emit_workflow(&wf, json, script, results, revive, revive_all, Some(r.path.as_path()));
}
println!("# {} workflow run(s) matching {name:?}:\n", hits.len());
for (r, w) in &hits {
println!(
"cv workflow {} {} # {} · {} · {} agent(s)",
short_id(&r.id),
w.run_id,
w.name.as_deref().unwrap_or("(unnamed)"),
w.status.as_deref().unwrap_or("?"),
w.agent_count,
);
}
Ok(())
}
fn list_workflows(r: &cv_core::SessionRef, json: bool) -> Result<()> {
let runs = cv_core::workflows_of(r);
let ghosts = cv_core::workflow_ghosts_of(r);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({"runs": runs, "ghost_launches": ghosts}))?
);
return Ok(());
}
if runs.is_empty() && ghosts.is_empty() {
println!("no workflows launched by {}", short_id(&r.id));
return Ok(());
}
if runs.is_empty() {
println!("# 0 recorded workflow(s) in {}", short_id(&r.id));
} else {
println!("# {} workflow(s) in {}\n", runs.len(), short_id(&r.id));
}
for w in &runs {
let status = w.status.as_deref().unwrap_or("?");
let states = w.state_counts();
let state_str = if states.is_empty() {
String::new()
} else {
let parts: Vec<String> = states.iter().map(|(k, n)| format!("{n} {k}")).collect();
format!(" [{}]", parts.join(", "))
};
println!(
"{} {} {} · {} phase(s) · {} agent(s){}",
w.run_id,
w.name.as_deref().unwrap_or("(unnamed)"),
status,
w.phases.len(),
w.agent_count,
state_str,
);
if let Some(s) = &w.summary {
println!(" {}", truncate(s, 120));
}
}
if !ghosts.is_empty() {
println!(
"\n⚠ {} launch(es) with NO recorded run — state never persisted (crash/kill before write?):",
ghosts.len()
);
for g in &ghosts {
println!(" {}", ghost_line(g));
}
println!(" → debris dirs live under the session dir's subagents/workflows/<runId>/ (`cv show <agent-id>` opens a transcript)");
}
println!("\n→ `cv workflow {} <runId>` for one run's phase tree", short_id(&r.id));
Ok(())
}
fn ghost_line(g: &cv_core::WorkflowLaunch) -> String {
let when =
g.ts.map(|t| crate::util::fmt_local(t, "%Y-%m-%d %H:%M"))
.unwrap_or_else(|| "(no timestamp)".into());
let mut line = format!("{} launched {when}", g.name.as_deref().unwrap_or("(unnamed)"));
if let Some(rid) = &g.run_id {
line.push_str(&format!(" · run {rid}"));
}
match (g.debris_agents, g.journal_result_count) {
(Some(0), Some(0)) => line.push_str(" · no debris"),
(Some(a), Some(j)) => line.push_str(&format!(" · DEBRIS: {a} agent transcript(s), {j} journaled result(s)")),
_ => {}
}
line
}
fn render_workflow(w: &cv_core::Workflow, show_script: bool) -> Result<()> {
println!(
"# workflow {} ({})",
w.name.as_deref().unwrap_or("(unnamed)"),
w.run_id
);
let dur = w
.duration_ms
.map(|ms| format!(" · {}", fmt_duration(ms)))
.unwrap_or_default();
let started = w
.started_at
.map(|t| format!(" · started {}", crate::util::fmt_local(t, "%Y-%m-%d %H:%M")))
.unwrap_or_default();
println!(
"{} · {} agent(s) · {} phase(s) · {} tokens · {} tool calls{}{}",
w.status.as_deref().unwrap_or("?"),
w.agent_count,
w.phases.len(),
fmt_int(w.total_tokens),
fmt_int(w.total_tool_calls),
dur,
started,
);
if let Some(m) = &w.default_model {
println!("model: {m}");
}
if let Some(t) = &w.task_id {
println!("task: {t}");
}
if let Some(a) = &w.args {
let (head, _) = cv_core::JsonView::from(a).canonical_prefix(4 * 201)?;
println!("args: {}", truncate(&head, 200));
}
if let Some(rf) = &w.resume_from {
println!("resumed from: {rf}");
}
if let Some(e) = &w.error {
println!("⚠ error: {}", truncate(e, 200));
}
if let Some(s) = &w.summary {
println!("\n{}", truncate(s, 600));
}
for p in &w.phases {
let title = p.title.as_deref().unwrap_or("(phase)");
println!(
"\n## phase {} · {}{}",
p.index,
title,
p.detail
.as_deref()
.map(|d| format!(" — {}", truncate(d, 80)))
.unwrap_or_default()
);
if p.agents.is_empty() {
println!(" (no agents)");
}
for a in &p.agents {
render_workflow_agent(a);
}
}
if !w.orphan_agents.is_empty() {
println!("\n## (agents with no matching phase)");
for a in &w.orphan_agents {
render_workflow_agent(a);
}
}
if !w.logs.is_empty() {
const TAIL: usize = 8;
println!("\n## log ({} line(s))", w.logs.len());
if w.logs.len() > TAIL {
println!(" … {} earlier (--json for all)", w.logs.len() - TAIL);
}
for l in w.logs.iter().rev().take(TAIL).rev() {
println!(" {}", truncate(l, 160));
}
}
match &w.result {
Some(r) => {
let (preview, truncated) = workflow_result_preview(r)?;
println!("\n## result");
if truncated {
println!("{}\n(truncated — `--json` for the full result)", preview);
} else {
println!("{preview}");
}
}
None => println!("\n(no result recorded — the run returned nothing or died before finishing)"),
}
if show_script {
match &w.script {
Some(src) => {
println!(
"\n## script{}",
w.script_path
.as_ref()
.map(|p| format!(" ({})", p.display()))
.unwrap_or_default()
);
println!("```js\n{src}\n```");
}
None => println!("\n(no script recorded)"),
}
} else if w.script.is_some() {
println!("\n→ `--script` to print the driving workflow script");
}
Ok(())
}
fn workflow_result_preview(value: &serde_json::Value) -> Result<(String, bool)> {
let (head, stopped) = cv_core::JsonView::from(value).pretty_prefix(4 * 2001)?;
let truncated = stopped || head.len() > 2000;
Ok((if truncated { truncate(&head, 2000) } else { head }, truncated))
}
fn render_workflow_agent(a: &cv_core::WorkflowAgent) {
let glyph = match a.state.as_deref() {
Some("done") => "✓",
Some("error") => "✗",
Some("progress") => "…",
Some("start") => "▸",
_ => "•",
};
let id = a.agent_id.as_deref().map(short_id).unwrap_or_else(|| "—".into());
let label = a.label.as_deref().unwrap_or("(agent)");
let mut tele = Vec::new();
if let Some(t) = a.tokens {
tele.push(format!("{} tok", fmt_int(t)));
}
if let Some(tc) = a.tool_calls {
tele.push(format!("{tc} calls"));
}
if let Some(ms) = a.duration_ms {
tele.push(fmt_duration(ms));
}
if a.cached {
tele.push("cached".into());
}
if let Some(n) = a.attempt.filter(|n| *n > 1) {
tele.push(format!("attempt {n}"));
}
let tele = if tele.is_empty() {
String::new()
} else {
format!(" ({})", tele.join(", "))
};
println!(" {glyph} {label} {id}{tele}");
if let Some(e) = &a.error {
println!(" ✗ {}", truncate(e, 120));
}
if !matches!(a.state.as_deref(), Some("done")) {
if let Some(tool) = &a.last_tool_name {
let when = a
.last_progress_at
.map(|t| format!(" @ {}", crate::util::fmt_local(t, "%m-%d %H:%M")))
.unwrap_or_default();
println!(
" ↪ last: {tool} · {}{when}",
a.last_tool_summary
.as_deref()
.map(|s| truncate(s, 100))
.unwrap_or_default(),
);
}
}
if let Some(rp) = &a.result_preview {
println!(" ↩ {}", truncate(rp, 200));
}
}
struct Revivable {
label: String,
agent_id: Option<String>,
phase: u64,
state: String,
error: Option<String>,
tokens: u64,
tool_calls: u64,
prompt: Option<String>,
files: Vec<String>,
commands: Vec<String>,
last_note: Option<String>,
}
fn render_revive(w: &cv_core::Workflow, all: bool, session_path: Option<&std::path::Path>) -> Result<()> {
let agents: Vec<&cv_core::WorkflowAgent> = w
.phases
.iter()
.flat_map(|p| &p.agents)
.chain(&w.orphan_agents)
.filter(|a| all || a.state.as_deref() != Some("done"))
.collect();
if agents.is_empty() {
println!(
"no revivable lanes in {} (every agent completed; `--revive --all` to include them)",
w.run_id
);
return Ok(());
}
println!("# revive {} — {} lane(s)", w.run_id, agents.len());
if let Some(e) = &w.error {
println!("run died: {}", truncate(e, 160));
}
println!(
"\nEach block below is a standalone Agent prompt. The lane's own transcript supplied the\n\
full task; the work log is mined from its tool calls so the revived lane does not redo it.\n"
);
for a in agents {
let rev = mine_lane(a, &w.run_id, session_path);
print_revive_block(&rev);
}
Ok(())
}
fn mine_lane(a: &cv_core::WorkflowAgent, run_id: &str, session_path: Option<&std::path::Path>) -> Revivable {
let mut rev = Revivable {
label: a.label.clone().unwrap_or_else(|| format!("agent#{}", a.index)),
agent_id: a.agent_id.clone(),
phase: a.phase_index,
state: a.state.clone().unwrap_or_else(|| "unknown".into()),
error: a.error.clone(),
tokens: a.tokens.unwrap_or(0),
tool_calls: a.tool_calls.unwrap_or(0),
prompt: a.prompt_preview.clone(),
files: Vec::new(),
commands: Vec::new(),
last_note: None,
};
let (Some(id), Some(sp)) = (a.agent_id.as_deref(), session_path) else {
return rev;
};
let Some(dir) = cv_core::harness::claude::sidecar::workflow_run_dir(sp, run_id) else {
return rev;
};
let bare = id.strip_prefix("agent-").unwrap_or(id);
let path = dir.join(format!("agent-{bare}.jsonl"));
let Ok(file) = std::fs::File::open(&path) else {
return rev;
};
mine_transcript_reader(std::io::BufReader::new(file), &mut rev);
rev
}
#[cfg(test)]
fn mine_transcript(text: &str, rev: &mut Revivable) {
mine_transcript_reader(std::io::Cursor::new(text.as_bytes()), rev);
}
fn mine_transcript_reader(reader: impl std::io::BufRead, rev: &mut Revivable) {
let mut seen_files = std::collections::BTreeSet::new();
let mut seen_cmds = std::collections::BTreeSet::new();
let mut got_prompt = false;
for line in std::io::BufRead::lines(reader) {
let line = match line {
Ok(line) => line,
Err(error) if error.kind() == std::io::ErrorKind::InvalidData => continue,
Err(_) => break,
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let role = v.pointer("/message/role").and_then(|r| r.as_str()).unwrap_or("");
let content = match v.pointer("/message/content") {
Some(c) => c,
None => continue,
};
if role == "user" && !got_prompt {
let full = match content {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(bs) => bs
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.next()
.map(str::to_string),
_ => None,
};
if let Some(f) = full.filter(|f| !f.trim().is_empty()) {
rev.prompt = Some(f);
got_prompt = true;
}
}
let serde_json::Value::Array(blocks) = content else {
continue;
};
for b in blocks {
match b.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
let name = b.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = b.get("input");
match name {
"Write" | "Edit" | "NotebookEdit" => {
if let Some(f) = input.and_then(|i| i.get("file_path")).and_then(|f| f.as_str()) {
if seen_files.insert(f.to_string()) {
rev.files.push(f.to_string());
}
}
}
"Bash" => {
if let Some(c) = input.and_then(|i| i.get("command")).and_then(|c| c.as_str()) {
let head: String = c.split('\n').next().unwrap_or(c).chars().take(70).collect();
if seen_cmds.insert(head.clone()) {
rev.commands.push(head);
}
}
}
_ => {}
}
}
Some("text") if role == "assistant" => {
if let Some(t) = b.get("text").and_then(|t| t.as_str()) {
if t.trim().len() > 80 {
rev.last_note = Some(t.to_string());
}
}
}
_ => {}
}
}
}
}
fn print_revive_block(rev: &Revivable) {
println!("\n{}", "=".repeat(78));
println!("## lane: {}", rev.label);
print!(
"state={} phase={} tokens={} tools={}",
rev.state,
rev.phase,
fmt_int(rev.tokens),
fmt_int(rev.tool_calls)
);
if let Some(id) = &rev.agent_id {
print!(" agent={}", short_id(id));
}
println!();
if let Some(e) = &rev.error {
println!("died: {}", truncate(e, 200));
}
if rev.prompt.is_none() {
println!("\n(no prompt recoverable — neither transcript nor preview; re-issue by hand)");
return;
}
let did_work = !rev.files.is_empty() || !rev.commands.is_empty();
println!("\n--- 8< --- paste the block below as an Agent prompt --- 8< ---\n");
println!("{}", rev.prompt.as_deref().unwrap_or_default());
if did_work {
println!(
"\n\n--- RESUMING AN INTERRUPTED RUN ---\n\
A previous run of this exact lane was cut short ({}) after {} tool calls. Its work is\n\
still on disk. Do NOT start over — verify what is listed below, then continue from there.",
rev.error
.as_deref()
.map(|e| truncate(e, 90))
.unwrap_or_else(|| rev.state.clone()),
fmt_int(rev.tool_calls),
);
if !rev.files.is_empty() {
println!("\nFiles it already wrote or edited ({}):", rev.files.len());
for f in rev.files.iter().take(40) {
println!(" {}", f);
}
if rev.files.len() > 40 {
println!(" … and {} more", rev.files.len() - 40);
}
}
if !rev.commands.is_empty() {
println!("\nCommands it ran ({} distinct, first 15):", rev.commands.len());
for c in rev.commands.iter().take(15) {
println!(" $ {}", c);
}
}
if let Some(note) = &rev.last_note {
println!(
"\nIts own last note, which usually says where it had got to:\n{}",
truncate(note, 900)
);
}
println!("\n--- END RESUME CONTEXT ---");
}
println!("\n--- >8 --- end prompt --- >8 ---");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bounded_workflow_result_preview_preserves_old_byte_threshold_and_character_cut() {
for size in [0, 499, 500, 501, 1998, 1999, 2000, 2001, 100000] {
for text in ["x", "🌙", "\"\\\n"] {
let value = serde_json::json!({"nested":[{},[],text.repeat(size)], "after":true});
let old = serde_json::to_string_pretty(&value).unwrap();
let truncated = old.len() > 2000;
let expected = if truncated { truncate(&old, 2000) } else { old };
assert_eq!(workflow_result_preview(&value).unwrap(), (expected, truncated));
let (args, _) = cv_core::JsonView::from(&value).canonical_prefix(4 * 201).unwrap();
assert_eq!(truncate(&args, 200), truncate(&value.to_string(), 200));
}
}
}
#[test]
fn revive_reader_recovers_valid_records_after_corrupt_utf8_and_large_ignored_results() {
let mut input = Vec::new();
input.extend_from_slice(b"invalid-json\n\xff\n");
let prompt = serde_json::json!({"message":{"role":"user","content":"full task"}});
let ignored = serde_json::json!({"message":{"role":"user","content":[{"type":"tool_result","content":"x".repeat(1100000)}]}});
let write = serde_json::json!({"message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"recovered.rs"}}]}});
for value in [prompt, ignored, write] {
input.extend_from_slice(format!("{value}\n").as_bytes());
}
let mut rev = Revivable {
label: "reader".into(),
agent_id: None,
phase: 0,
state: "error".into(),
error: None,
tokens: 0,
tool_calls: 0,
prompt: Some("preview".into()),
files: vec![],
commands: vec![],
last_note: None,
};
mine_transcript_reader(
std::io::BufReader::with_capacity(17, std::io::Cursor::new(input)),
&mut rev,
);
assert_eq!(rev.prompt.as_deref(), Some("full task"));
assert_eq!(rev.files, vec!["recovered.rs"]);
}
#[test]
fn revive_mines_prompt_files_commands_and_last_note() {
let lines = [
r#"{"type":"user","message":{"role":"user","content":"Full lane task: implement the widget end to end."}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Starting; I will scaffold first and then wire the tests, keeping the public surface unchanged for now."},{"type":"tool_use","name":"Write","input":{"file_path":"/repo/src/widget.rs","content":"..."}}]}}"#,
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test -p widget\necho done"}},{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/widget.rs"}},{"type":"tool_use","name":"Bash","input":{"command":"cargo test -p widget"}}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Scaffold and tests landed in widget.rs; the remaining piece is the CLI flag, which I had not started when interrupted."}]}}"#,
]
.join("\n");
let mut rev = Revivable {
label: "widget".into(),
agent_id: Some("agent-1".into()),
phase: 0,
state: "error".into(),
error: Some("session limit".into()),
tokens: 0,
tool_calls: 4,
prompt: Some("Full lane task: impl…".into()), files: vec![],
commands: vec![],
last_note: None,
};
mine_transcript(&lines, &mut rev);
assert_eq!(
rev.prompt.as_deref(),
Some("Full lane task: implement the widget end to end."),
"the FULL first user prompt replaces the preview"
);
assert_eq!(
rev.files,
vec!["/repo/src/widget.rs"],
"Write + Edit of one file dedupe"
);
assert_eq!(rev.commands, vec!["cargo test -p widget"], "first line only, deduped");
assert!(rev
.last_note
.as_deref()
.is_some_and(|n| n.starts_with("Scaffold and tests landed")));
}
}