use std::collections::BTreeSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use clap::Subcommand;
use cv_core::ir::truncate;
use cv_core::sanitize::sanitize_line;
use cv_core::task::{self, TaskEventKind, TaskProjection, TaskReadModel, TaskRow, TaskStore};
use super::task_ops::{self, DecisionSpec, EventFilter, Since};
use crate::util::fmt_local;
fn read_text_arg(path: &Path) -> Result<String> {
if path == Path::new("-") {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("reading the text from stdin")?;
return Ok(buf);
}
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))
}
fn parse_tags(csv: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for t in csv.split(',').map(str::trim).filter(|t| !t.is_empty()) {
if !out.iter().any(|have| have == t) {
out.push(t.to_string());
}
}
out
}
pub(crate) fn prefix_len(model: &TaskReadModel) -> usize {
task::unique_prefix_len(model.tasks.keys().map(String::as_str), 8)
}
pub(crate) fn prefix(id: &str, len: usize) -> &str {
id.get(..len).unwrap_or(id)
}
fn text_or_stdin(text: String) -> Result<String> {
if text == "-" {
read_text_arg(Path::new("-"))
} else {
Ok(text)
}
}
fn absolutize_issue(issue: Option<String>, repo: Option<&Path>) -> Option<String> {
let issue = issue?;
let Some(p) = issue_path(&issue) else {
return Some(issue);
};
let p = Path::new(p);
if p.is_absolute() {
return Some(issue);
}
let cwd = std::env::current_dir().ok();
let in_cwd = cwd.as_ref().map(|d| d.join(p));
let in_repo = repo.map(|r| r.join(p));
let chosen = match (&in_cwd, &in_repo) {
(Some(c), _) if c.exists() => c.clone(),
(_, Some(r)) if r.exists() => r.clone(),
(Some(c), _) => c.clone(),
(None, Some(r)) => r.clone(),
(None, None) => p.to_path_buf(),
};
let abs = chosen.canonicalize().unwrap_or(chosen);
Some(abs.display().to_string())
}
fn scope_window(all: bool, since: Option<&str>, now: DateTime<Utc>) -> Result<Option<DateTime<Utc>>> {
if all {
return Ok(None);
}
match since {
Some(s) => Ok(Some(task::parse_since(s, now).map_err(|e| anyhow::anyhow!(e))?)),
None => Ok(Some(now - chrono::Duration::days(14))),
}
}
fn from_or_cv(explicit: Option<String>) -> String {
task::actor(explicit, "cv")
}
fn require_from(explicit: Option<String>) -> Result<String> {
task::require_actor(explicit, "--from")
}
#[derive(Subcommand)]
pub(crate) enum TaskCmd {
Open {
title: String,
#[arg(long, default_value = "", conflicts_with = "body_file")]
body: String,
#[arg(long = "body-file", value_name = "PATH")]
body_file: Option<PathBuf>,
#[arg(long)]
repo: Option<PathBuf>,
#[arg(long)]
issue: Option<String>,
#[arg(long, default_value = "tasks")]
channel: String,
#[arg(long)]
assignee: Option<String>,
#[arg(long, value_name = "A,B")]
tags: Option<String>,
#[arg(long = "blocked-by", value_name = "ID")]
blocked_by: Vec<String>,
#[arg(long = "blocks", value_name = "ID")]
blocks: Vec<String>,
#[arg(long)]
from: Option<String>,
},
List {
#[arg(long)]
state: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
repo: Option<PathBuf>,
#[arg(long)]
tag: Option<String>,
#[arg(long, group = "kind")]
decisions: bool,
#[arg(long, group = "kind")]
actions: bool,
#[arg(long)]
all: bool,
#[arg(long, value_name = "WHEN", conflicts_with = "all")]
since: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
tsv: bool,
#[arg(long, conflicts_with_all = ["json", "tsv"])]
wide: bool,
},
Show {
id: String,
#[arg(long)]
json: bool,
#[arg(long)]
events: bool,
#[arg(long)]
brief: bool,
#[arg(long, conflicts_with_all = ["json", "brief", "events"])]
status: bool,
#[arg(long = "notes-last", value_name = "N")]
notes_last: Option<usize>,
#[arg(long = "notes-grep", value_name = "PATTERN")]
notes_grep: Option<String>,
},
Claim {
id: String,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Release {
id: String,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Note {
id: String,
#[arg(required_unless_present = "file", conflicts_with = "file")]
text: Option<String>,
#[arg(long, value_name = "PATH")]
file: Option<PathBuf>,
#[arg(long = "session-ref")]
session_ref: Option<String>,
#[arg(long)]
from: Option<String>,
},
Done {
id: String,
#[arg(long)]
observed: Option<String>,
#[arg(long = "check-cmd", group = "check")]
check_cmd: Option<String>,
#[arg(long = "check-file", group = "check")]
check_file: Option<PathBuf>,
#[arg(long = "check-http", group = "check")]
check_http: Option<String>,
#[arg(long, conflicts_with = "note_file")]
note: Option<String>,
#[arg(long = "note-file", value_name = "PATH")]
note_file: Option<PathBuf>,
#[arg(long)]
from: Option<String>,
},
Status {
id: String,
#[arg(required_unless_present = "file", conflicts_with = "file")]
text: Option<String>,
#[arg(long, value_name = "PATH")]
file: Option<PathBuf>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Abandon {
id: String,
#[arg(long, default_value = "no reason given")]
reason: String,
#[arg(long, conflicts_with = "note_file")]
note: Option<String>,
#[arg(long = "note-file", value_name = "PATH")]
note_file: Option<PathBuf>,
#[arg(long)]
from: Option<String>,
},
Supersede {
id: String,
#[arg(long = "by")]
by_task: String,
#[arg(long)]
from: Option<String>,
},
Tag {
id: String,
#[arg(value_name = "A,B")]
tags: String,
#[arg(long)]
from: Option<String>,
},
Block {
id: String,
#[arg(long = "by", value_name = "ID")]
by_task: String,
#[arg(long)]
from: Option<String>,
},
Decide {
title: String,
#[arg(long = "for", value_name = "WHO")]
for_who: String,
#[arg(long, value_name = "OPTION")]
default: String,
#[arg(long = "option", value_name = "OPTION")]
options: Vec<String>,
#[arg(long = "by", value_name = "WHEN")]
by: Option<String>,
#[arg(long, default_value = "", conflicts_with = "body_file")]
body: String,
#[arg(long = "body-file", value_name = "PATH")]
body_file: Option<PathBuf>,
#[arg(long)]
repo: Option<PathBuf>,
#[arg(long)]
issue: Option<String>,
#[arg(long, default_value = "tasks")]
channel: String,
#[arg(long, value_name = "A,B")]
tags: Option<String>,
#[arg(long = "blocks", value_name = "ID")]
blocks: Vec<String>,
#[arg(long)]
provisional: bool,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Resolve {
id: String,
#[arg(long, value_name = "OPTION", group = "answer", required = true)]
choice: Option<String>,
#[arg(long = "accept-default", group = "answer")]
accept_default: bool,
#[arg(long, group = "answer")]
confirm: bool,
#[arg(long, conflicts_with = "confirm")]
provisional: bool,
#[arg(long, conflicts_with = "note_file")]
note: Option<String>,
#[arg(long = "note-file", value_name = "PATH")]
note_file: Option<PathBuf>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Split {
id: String,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(long)]
from: Option<String>,
},
Events {
#[arg(long, value_name = "WHEN")]
since: Option<String>,
#[arg(long, value_name = "K,K")]
kind: Option<String>,
#[arg(long)]
by: Option<String>,
#[arg(long = "not-by", value_name = "WHO")]
not_by: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
task: Option<String>,
#[arg(long)]
text: bool,
},
Watch {
#[arg(long)]
assignee: String,
#[arg(long, value_name = "WHEN")]
since: Option<String>,
#[arg(long = "not-by", value_name = "WHO")]
not_by: Option<String>,
#[arg(long)]
text: bool,
},
Serve {
#[arg(long, default_value = "127.0.0.1:7777")]
bind: String,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
open: bool,
#[arg(long)]
token: Option<String>,
},
Sweep {
#[arg(long)]
repo: PathBuf,
#[arg(long)]
main: Option<String>,
#[arg(long)]
json: bool,
},
Propose {
id: String,
#[arg(long)]
branch: String,
#[arg(long)]
sha: Option<String>,
#[arg(long, default_value = "origin/main")]
upstream: String,
#[arg(long)]
worktree: Option<PathBuf>,
#[arg(long)]
reviewer: Option<String>,
#[arg(long = "session-ref")]
session_ref: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Reroute {
id: String,
#[arg(long)]
to: String,
#[arg(long)]
from: Option<String>,
},
Pass {
id: String,
#[arg(long)]
session: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Refute {
id: String,
#[arg(long)]
session: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
token: Option<String>,
},
Verify {
id: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
fetch: bool,
#[arg(long = "skip-landed")]
skip_landed: bool,
},
Inbox {
who: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
md: bool,
#[arg(long)]
unread: bool,
#[arg(long)]
all: bool,
#[arg(long, value_name = "WHEN", conflicts_with = "all")]
since: Option<String>,
},
Debt {
#[arg(long)]
repo: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Stats {
#[arg(long)]
repo: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
fn replay_loud() -> Result<cv_core::task::ReplayOutcome> {
let outcome = task::replay()?;
for w in &outcome.warnings {
eprintln!("⚠ {}", sanitize_line(w));
}
Ok(outcome)
}
fn append_and_report(task_id: Option<&str>, from: &str, kind: TaskEventKind, token: Option<String>) -> Result<()> {
append_event(task_id, from, kind, token).map(|_| ())
}
fn append_event(task_id: Option<&str>, from: &str, kind: TaskEventKind, token: Option<String>) -> Result<String> {
let store = TaskStore::default_store().with_token(task::token(token));
let report = task::append_and_notify(&store, task_id, from, kind, Vec::new())?;
for w in report.replay_warnings.iter().chain(&report.warnings) {
eprintln!("⚠ {}", sanitize_line(w));
}
let state = report.effective_state.unwrap_or_else(|| "?".into());
println!(
"✦ {} {} → {}",
report.event.kind.tag(),
prefix(&report.event.task_id, 13),
state
);
if matches!(report.event.kind, TaskEventKind::Opened { .. }) {
println!("{}", report.event.task_id);
}
Ok(report.event.task_id)
}
fn note_arg(note: Option<String>, note_file: Option<PathBuf>) -> Result<Option<String>> {
let text = match (note, note_file) {
(Some(n), _) => text_or_stdin(n)?,
(None, Some(f)) => read_text_arg(&f)?,
(None, None) => return Ok(None),
};
Ok(Some(text).filter(|t| !t.trim().is_empty()))
}
fn close_with_note(id: &str, from: &str, note: Option<String>, close: TaskEventKind) -> Result<()> {
let Some(text) = note else {
return append_and_report(Some(id), from, close, None);
};
let store = TaskStore::default_store();
let report = task::append_batch_and_notify(
&store,
from,
vec![
(
Some(id.to_string()),
TaskEventKind::Noted {
text,
session_ref: None,
},
),
(Some(id.to_string()), close),
],
Vec::new(),
)?;
for w in report.replay_warnings.iter().chain(&report.warnings) {
eprintln!("⚠ {}", sanitize_line(w));
}
let state = report.effective_state.unwrap_or_else(|| "?".into());
for ev in &report.events {
println!("✦ {} {} → {}", ev.kind.tag(), prefix(&ev.task_id, 13), state);
}
Ok(())
}
fn resolve<'m>(model: &'m cv_core::task::TaskReadModel, prefix: &str) -> Result<&'m str> {
task::resolve_id(model, prefix).map_err(|e| anyhow::anyhow!(e))
}
fn task_row(r: &TaskRow, now: DateTime<Utc>, plen: usize, blocked: bool) -> String {
let assignee = r.assignee.as_deref().unwrap_or("-");
format!(
"{:<plen$} {:>4} {:16} {:20} {}{}",
prefix(&r.id, plen),
task::age_short(r.last_ts.unwrap_or(now), now),
r.effective_state,
sanitize_line(assignee),
if blocked { "⊘ " } else { "" },
sanitize_line(&r.title)
)
}
fn task_row_tsv(r: &TaskRow, now: DateTime<Utc>) -> String {
let cell = |s: &str| sanitize_line(s).replace(['\t', '\n'], " ");
let repo = r
.repo
.as_deref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
[
r.id.clone(),
r.effective_state.clone(),
cell(r.assignee.as_deref().unwrap_or("")),
cell(&repo),
task::age_short(r.last_ts.unwrap_or(now), now),
cell(&r.title),
r.blocked_by.join(","),
]
.join("\t")
}
fn task_row_wide(t: &TaskProjection, model: &TaskReadModel, now: DateTime<Utc>, plen: usize) -> String {
let mut out = task_row(&TaskRow::full(t), now, plen, task::is_blocked(model, t));
let mut facts: Vec<String> = Vec::new();
if !t.tags.is_empty() {
facts.push(format!("#{}", t.tags.join(" #")));
}
if let Some(repo) = &t.repo {
facts.push(
repo.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
);
}
if !t.blocked_by.is_empty() {
let names: Vec<String> = t
.blocked_by
.iter()
.map(|b| match model.tasks.get(b) {
Some(bt) => format!("{} [{}]", prefix(b, plen), task::effective_display(bt)),
None => format!("{} [unknown]", prefix(b, plen)),
})
.collect();
facts.push(format!("blocked by {}", names.join(", ")));
}
let body = t.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
if !body.is_empty() {
facts.push(truncate(&sanitize_line(body), 160));
}
if !facts.is_empty() {
out.push_str("\n ");
out.push_str(&sanitize_line(&facts.join(" · ")));
}
out
}
pub(crate) fn cmd_task(action: TaskCmd) -> Result<()> {
match action {
TaskCmd::Open {
title,
body,
body_file,
repo,
issue,
channel,
assignee,
tags,
blocked_by,
blocks,
from,
} => {
let repo = match repo {
Some(r) => Some(
r.canonicalize()
.with_context(|| format!("repo {} not found", r.display()))?,
),
None => None,
};
let issue = absolutize_issue(issue, repo.as_deref());
let body = match &body_file {
Some(p) => read_text_arg(p)?,
None => text_or_stdin(body)?,
};
let tags = tags.as_deref().map(parse_tags).unwrap_or_default();
task_ops::refuse_bare_decision_tag(&tags, &title, None)?;
let (blocked_by, blocks) = {
let outcome = replay_loud()?;
let res = |ids: Vec<String>| -> Result<Vec<String>> {
ids.iter()
.map(|p| resolve(&outcome.model, p).map(str::to_string))
.collect()
};
(res(blocked_by)?, res(blocks)?)
};
let from = from_or_cv(from);
let id = append_event(
None,
&from,
TaskEventKind::Opened {
title,
body,
repo,
issue,
channel,
assignee,
},
None,
)?;
if !tags.is_empty() {
append_and_report(Some(&id), &from, TaskEventKind::Tagged { tags }, None)?;
}
for b in blocked_by {
append_and_report(Some(&id), &from, TaskEventKind::BlockedBy { task: b }, None)?;
}
for other in blocks {
append_and_report(Some(&other), &from, TaskEventKind::BlockedBy { task: id.clone() }, None)?;
}
Ok(())
}
TaskCmd::List {
state,
assignee,
repo,
tag,
decisions,
actions,
all,
since,
json,
tsv,
wide,
} => {
let outcome = replay_loud()?;
let now = Utc::now();
let touched_since = scope_window(all, since.as_deref(), now)?;
let caller = task::default_endpoint();
let filter = task::TaskFilter {
state,
assignee,
repo,
include_terminal: all,
tag,
touched_since,
or_involving: caller,
decisions: if decisions {
Some(true)
} else if actions {
Some(false)
} else {
None
},
};
let tasks = task::list(&outcome.model, &filter).map_err(|e| anyhow::anyhow!(e))?;
let hidden = if touched_since.is_some() {
let unscoped = task::TaskFilter {
touched_since: None,
or_involving: None,
..filter.clone()
};
task::list(&outcome.model, &unscoped)
.map(|v| v.len().saturating_sub(tasks.len()))
.unwrap_or(0)
} else {
0
};
if json {
println!("{}", serde_json::to_string_pretty(&tasks)?);
return Ok(());
}
let now = Utc::now();
if tsv {
for t in &tasks {
println!("{}", task_row_tsv(&TaskRow::full(t), now));
}
return Ok(());
}
let plen = prefix_len(&outcome.model);
for t in &tasks {
if wide {
println!("{}", task_row_wide(t, &outcome.model, now, plen));
} else {
println!(
"{}",
task_row(&TaskRow::full(t), now, plen, task::is_blocked(&outcome.model, t))
);
}
}
if tasks.is_empty() {
println!("(no matching tasks)");
} else if tasks.iter().any(|t| task::is_blocked(&outcome.model, t)) {
println!("⊘ = blocked by a task that has not finished");
}
if hidden > 0 {
println!("({hidden} older task(s) hidden — `--all`, or `--since 90d`)");
}
Ok(())
}
TaskCmd::Show {
id,
json,
events,
brief,
status,
notes_last,
notes_grep,
} => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let t = &outcome.model.tasks[&id];
if status {
let Some(st) = &t.status else {
bail!(
"no STATUS pinned on {} — `cv task status {} --file F` pins one",
prefix(&id, 13),
prefix(&id, 13)
);
};
for line in st.text.lines() {
println!("{}", sanitize_line(line));
}
return Ok(());
}
let grep = match ¬es_grep {
Some(p) => Some(regex::RegexBuilder::new(p).case_insensitive(true).build()?),
None => None,
};
if json {
println!("{}", serde_json::to_string_pretty(t)?);
} else {
println!("task {} [{}] {}", t.task_id, task::effective_display(t), t.kind());
if let Some(st) = &t.status {
println!(
" STATUS ({}, {}{}):",
sanitize_line(&st.by),
fmt_local(st.ts, "%Y-%m-%d %H:%M"),
if st.revisions > 1 {
format!(", revision {}", st.revisions)
} else {
String::new()
}
);
for line in st.text.lines() {
println!(" {}", sanitize_line(line));
}
if brief {
println!(" title: {}", sanitize_line(&t.title));
return Ok(());
}
}
let hb = cv_core::task::verify::read_heartbeat(&task::tasks_dir());
let hb_ts = hb.as_ref().map(|h| h.ts);
let hb_interval = hb.as_ref().and_then(|h| h.interval_secs);
let show_now = Utc::now();
if t.state == cv_core::task::TaskState::Done {
let (prov, detail) = match &t.done_check {
Some(dc) => (
cv_core::task::Provenance::checked(Some(t.last_ts)),
format!(" ({})", sanitize_line(&describe_check(dc))),
),
None => (
cv_core::task::Provenance::self_reported(Some(t.last_ts)),
t.done_observed
.as_deref()
.map(|o| format!(" ({})", sanitize_line(o)))
.unwrap_or_default(),
),
};
println!(" done: {}{}", freshness_phrase(&prov, show_now), detail);
}
println!(" title: {}", sanitize_line(&t.title));
if !t.body.is_empty() {
if brief {
println!(" body: {}", task_ops::first_line(&t.body, 160));
} else {
println!(" body: {}", sanitize_line(&t.body));
}
}
if let Some(repo) = &t.repo {
println!(" repo: {}", repo.display());
}
if let Some(issue) = &t.issue {
println!(" issue: {}", sanitize_line(issue));
}
println!(" channel: #{}", sanitize_line(&t.channel));
println!(" assignee: {}", sanitize_line(t.assignee.as_deref().unwrap_or("-")));
if !t.tags.is_empty() {
println!(" tags: {}", sanitize_line(&t.tags.join(", ")));
}
let plen = prefix_len(&outcome.model);
for b in &t.blocked_by {
match outcome.model.tasks.get(b) {
Some(bt) => println!(
" blocked by: {} [{}] {}",
prefix(b, plen),
task::effective_display(bt),
sanitize_line(&bt.title)
),
None => println!(" blocked by: {} [unknown task]", prefix(b, plen)),
}
}
if task::is_blocked(&outcome.model, t) {
println!(" ⊘ BLOCKED — a blocker above has not finished");
}
for other in task::blocks(&outcome.model, &t.task_id) {
println!(
" blocks: {} [{}] {}",
prefix(&other.task_id, plen),
task::effective_display(other),
sanitize_line(&other.title)
);
}
println!(
" opened: {} by {}",
fmt_local(t.opened_at, "%Y-%m-%d %H:%M"),
sanitize_line(&t.opened_by)
);
if let Some(d) = &t.decision {
let deadline = d
.deadline
.map(|dl| format!(" · {}", task_ops::deadline_phrase(dl, show_now)))
.unwrap_or_default();
println!(
" decision: posed {} by {}{deadline}",
fmt_local(d.posed_at, "%Y-%m-%d %H:%M"),
sanitize_line(&d.posed_by)
);
println!(" default: {}", sanitize_line(&d.default_choice));
for a in d.alternatives() {
println!(" option: {}", sanitize_line(a));
}
if let Some(p) = &d.superseded_provisional {
println!(
" provisionally: {} — by {}, {} (then answered by the decider, below)",
sanitize_line(&p.choice),
sanitize_line(&p.by),
fmt_local(p.ts, "%Y-%m-%d %H:%M"),
);
}
match &d.resolution {
Some(r) => {
println!(
" resolved: {} — by {}, {}{}{}{}",
sanitize_line(&r.choice),
sanitize_line(&r.by),
fmt_local(r.ts, "%Y-%m-%d %H:%M"),
if r.accepted_default { " (the default)" } else { "" },
if r.provisional { " PROVISIONAL" } else { "" },
r.note
.as_deref()
.map(|n| format!(" · {}", sanitize_line(n)))
.unwrap_or_default()
);
if r.provisional {
println!(
" veto?: cv task resolve {} --confirm --from {owner} · or --choice \"<option>\"",
prefix(&t.task_id, plen),
owner = sanitize_line(t.assignee.as_deref().unwrap_or("<you>"))
);
}
}
None if !t.state.is_terminal() => println!(
" resolve: cv task resolve {} --accept-default --from {}",
prefix(&t.task_id, plen),
sanitize_line(t.assignee.as_deref().unwrap_or("<you>"))
),
None => {}
}
}
for rev in &t.revisions {
println!(
" rev {}: {} [{}] {} → {}",
rev.revision.n,
sanitize_line(&rev.revision.branch),
rev.state.as_str(),
&rev.revision.review_sha[..12],
sanitize_line(&rev.revision.upstream),
);
if let Some(reviewer) = &rev.active_reviewer {
println!(" reviewer: {}", sanitize_line(reviewer));
}
for (verdict, receipts) in [
("pass", rev.pass.as_ref().and_then(|p| p.receipts.as_ref())),
("refute", rev.refute.as_ref().and_then(|r| r.receipts.as_ref())),
] {
if let Some(r) = receipts {
println!(" receipts ({verdict}): {}", sanitize_line(&receipts_line(r)));
}
}
if let Some((head, pid)) = &rev.landed {
let observed = rev
.landed_at
.map(|at| format!("observed {} ago", task::age_short(at, show_now)))
.unwrap_or_else(|| "observation time unknown".into());
let qualifier = match rev.landed_at.map(|at| {
cv_core::task::Provenance::git_verified(at, hb_ts, hb_interval, show_now).freshness
}) {
Some(cv_core::task::Freshness::Stale { .. }) => " (verifier stale)",
Some(cv_core::task::Freshness::Unknown) => " (unconfirmed by latest pass)",
_ => "",
};
println!(
" landed: upstream {} (patch-id {}) · git-verified, {}{}",
&head[..12],
&pid[..12],
observed,
qualifier
);
}
for issue in &rev.issues {
println!(" ⚠ {}", sanitize_line(&issue.describe()));
}
}
let children = task_ops::split_children(&outcome.model);
let selected: Vec<&cv_core::task::Note> = t
.notes
.iter()
.filter(|n| grep.as_ref().is_none_or(|re| re.is_match(&n.text)))
.collect();
let skip = notes_last.map_or(0, |k| selected.len().saturating_sub(k));
let shown = &selected[skip..];
if !t.notes.is_empty() && (brief || shown.len() != t.notes.len()) {
println!(
" notes: {} of {} (newest last){}",
shown.len(),
t.notes.len(),
if brief {
" — one line each; drop --brief for the full text"
} else {
""
}
);
}
for note in shown {
let split = children
.get(¬e.event_id)
.map(|c| format!(" → split into {}", prefix(c, plen)))
.unwrap_or_default();
let late = if note.post_close { " (after close)" } else { "" };
if brief {
println!(
" {} {:<24} {}{late}{split}",
fmt_local(note.ts, "%m-%d %H:%M"),
truncate(&sanitize_line(¬e.by), 24),
task_ops::first_line(¬e.text, 150)
);
} else {
println!(
" note ({}, {}){late}: {}{split}",
sanitize_line(¬e.by),
fmt_local(note.ts, "%Y-%m-%d %H:%M"),
sanitize_line(¬e.text)
);
}
}
}
if events {
let history: Vec<_> = outcome.events.iter().filter(|e| e.task_id == id).collect();
println!("{}", serde_json::to_string_pretty(&history)?);
}
Ok(())
}
TaskCmd::Claim { id, from, token } => {
let from = require_from(from)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
append_and_report(
Some(&id),
&from,
TaskEventKind::Claimed { assignee: from.clone() },
token,
)
}
TaskCmd::Release { id, from, token } => {
let from = require_from(from)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
append_and_report(Some(&id), &from, TaskEventKind::Released {}, token)
}
TaskCmd::Note {
id,
text,
file,
session_ref,
from,
} => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let text = match (text, file) {
(Some(t), _) => text_or_stdin(t)?,
(None, Some(f)) => read_text_arg(&f)?,
(None, None) => unreachable!("clap requires text or --file"),
};
append_and_report(
Some(&id),
&from_or_cv(from),
TaskEventKind::Noted { text, session_ref },
None,
)
}
TaskCmd::Done {
id,
observed,
check_cmd,
check_file,
check_http,
note,
note_file,
from,
} => {
let note = note_arg(note, note_file)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let t = &outcome.model.tasks[&id];
let spec = check_cmd
.map(cv_core::task::CheckSpec::Cmd)
.or_else(|| check_file.map(cv_core::task::CheckSpec::File))
.or_else(|| check_http.map(cv_core::task::CheckSpec::Http));
let (observed, check) = match spec {
Some(spec) => {
let done_check = spec.run(t.repo.as_deref())?;
let observed = observed.or_else(|| Some(done_check.result.clone()));
println!("✓ completion check passed: {}", sanitize_line(&done_check.result));
(observed, Some(done_check))
}
None => (observed, None),
};
close_with_note(&id, &from_or_cv(from), note, TaskEventKind::Done { observed, check })
}
TaskCmd::Status {
id,
text,
file,
from,
token,
} => {
let from = require_from(from)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let text = match (text, file) {
(Some(t), _) => text_or_stdin(t)?,
(None, Some(f)) => read_text_arg(&f)?,
(None, None) => unreachable!("clap requires text or --file"),
};
let text = text.trim_end().to_string();
append_and_report(Some(&id), &from, TaskEventKind::StatusSet { text }, token)
}
TaskCmd::Abandon {
id,
reason,
note,
note_file,
from,
} => {
let note = note_arg(note, note_file)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
close_with_note(&id, &from_or_cv(from), note, TaskEventKind::Abandoned { reason })
}
TaskCmd::Supersede { id, by_task, from } => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let by_task = resolve(&outcome.model, &by_task)?.to_string();
append_and_report(
Some(&id),
&from_or_cv(from),
TaskEventKind::Superseded { by_task },
None,
)
}
TaskCmd::Tag { id, tags, from } => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let tags = parse_tags(&tags);
if tags.is_empty() {
bail!("no tags given (comma-separated, e.g. `deploy,lean`)");
}
let t = &outcome.model.tasks[&id];
task_ops::refuse_bare_decision_tag(&tags, &t.title, Some(t))?;
append_and_report(Some(&id), &from_or_cv(from), TaskEventKind::Tagged { tags }, None)
}
TaskCmd::Block { id, by_task, from } => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let by_task = resolve(&outcome.model, &by_task)?.to_string();
append_and_report(
Some(&id),
&from_or_cv(from),
TaskEventKind::BlockedBy { task: by_task },
None,
)
}
TaskCmd::Sweep { repo, main, json } => cmd_sweep(&repo, main, json),
TaskCmd::Propose {
id,
branch,
sha,
upstream,
worktree,
reviewer,
session_ref,
from,
token,
} => {
let from = require_from(from)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let t = &outcome.model.tasks[&id];
let repo = t
.repo
.clone()
.context("task has no --repo recorded; open it with one to propose revisions")?;
let n = t.revisions.len() as u32 + 1;
let revision = cv_core::task::verify::observe_revision(
&repo,
&branch,
&upstream,
sha.as_deref(),
n,
worktree,
reviewer,
session_ref,
)?;
println!(
"observed: {} tip {} range-patch-id {}",
sanitize_line(&branch),
&revision.review_sha[..12],
&revision.patch_id[..12]
);
for w in task::propose_collision_warnings(&outcome.model, &id, &repo, &revision) {
eprintln!("⚠ WARNING: {}", sanitize_line(&w));
}
append_and_report(Some(&id), &from, TaskEventKind::RevisionProposed { revision }, token)
}
TaskCmd::Reroute { id, to, from } => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let current = outcome.model.tasks[&id]
.current_revision()
.and_then(|r| r.active_reviewer.clone())
.context("no active reviewer to reroute from")?;
append_and_report(
Some(&id),
&from_or_cv(from),
TaskEventKind::ReviewRerouted { from: current, to },
None,
)
}
TaskCmd::Pass {
id,
session,
from,
token,
} => {
let from = require_from(from)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let obs = task::review_observation(&outcome.model.tasks[&id], session.as_deref());
if let Some(w) = task::independence_warning(obs.independence.as_ref()) {
eprintln!("⚠ {w}");
}
if let Some(w) = task::receipts_warning(obs.receipts.as_ref()) {
eprintln!("⚠ {w}");
}
append_and_report(
Some(&id),
&from,
TaskEventKind::ReviewPassed {
reviewer: from.clone(),
session_ref: session,
independence: obs.independence,
receipts: obs.receipts,
},
token,
)
}
TaskCmd::Refute {
id,
session,
from,
token,
} => {
let from = require_from(from)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let receipts = task::review_receipts(&outcome.model.tasks[&id], session.as_deref());
if let Some(w) = task::receipts_warning(receipts.as_ref()) {
eprintln!("⚠ {w}");
}
append_and_report(
Some(&id),
&from,
TaskEventKind::ReviewRefuted {
reviewer: from.clone(),
session_ref: session,
receipts,
},
token,
)
}
TaskCmd::Verify {
id,
all,
fetch,
skip_landed,
} => cmd_verify(id, all, fetch, skip_landed),
TaskCmd::Inbox {
who,
json,
md,
unread,
all,
since,
} => {
let who = from_or_cv(who);
let outcome = replay_loud()?;
let now = Utc::now();
let window = scope_window(all, since.as_deref(), now)?;
let caller = task::default_endpoint();
let page = task_ops::inbox_page(&outcome, &who, caller.as_deref(), window, unread, now);
if json {
let keep: std::collections::HashSet<&str> = page.items.iter().map(|i| i.id.as_str()).collect();
let entries: Vec<_> = task::inbox(&outcome.model, &who)
.into_iter()
.filter(|e| keep.contains(e.task.task_id.as_str()))
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if md {
print!("{}", task_ops::render_inbox_md(&page));
} else {
print!("{}", task_ops::render_inbox_text(&page));
}
Ok(())
}
TaskCmd::Decide {
title,
for_who,
default,
options,
by,
body,
body_file,
repo,
issue,
channel,
tags,
blocks,
provisional,
from,
token,
} => {
let repo = match repo {
Some(r) => Some(
r.canonicalize()
.with_context(|| format!("repo {} not found", r.display()))?,
),
None => None,
};
let body = match &body_file {
Some(p) => read_text_arg(p)?,
None => text_or_stdin(body)?,
};
let now = Utc::now();
let deadline = match by {
Some(b) => Some(task::parse_deadline(&b, now).map_err(|e| anyhow::anyhow!(e))?),
None => None,
};
let blocks = {
let outcome = replay_loud()?;
blocks
.iter()
.map(|p| resolve(&outcome.model, p).map(str::to_string))
.collect::<Result<Vec<_>>>()?
};
let issue = absolutize_issue(issue, repo.as_deref());
let spec = DecisionSpec {
title,
body,
for_who,
default_choice: default,
options,
deadline,
repo,
issue,
channel,
tags: tags.as_deref().map(parse_tags).unwrap_or_default(),
source: None,
blocks,
provisional,
};
let store = TaskStore::default_store().with_token(task::token(token));
let from = from_or_cv(from);
let (events, warnings) = task_ops::pose(&store, &from, spec)?;
for w in &warnings {
eprintln!("⚠ {}", sanitize_line(w));
}
let id = events[0].task_id.clone();
let for_who = match &events[0].kind {
TaskEventKind::Opened { assignee, .. } => assignee.clone().unwrap_or_default(),
_ => String::new(),
};
println!("✦ decision {} posed for {}", prefix(&id, 13), sanitize_line(&for_who));
if events
.iter()
.any(|e| matches!(&e.kind, TaskEventKind::Resolved { provisional: true, .. }))
{
println!(
"✦ resolved {} provisionally on the default (by {}) — {} can --confirm or veto",
prefix(&id, 13),
sanitize_line(&from),
sanitize_line(&for_who)
);
}
println!("{id}");
Ok(())
}
TaskCmd::Resolve {
id,
choice,
accept_default,
confirm,
provisional,
note,
note_file,
from,
token,
} => {
let note = note_arg(note, note_file)?;
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let t = &outcome.model.tasks[&id];
let from = match from.or_else(task::default_endpoint) {
Some(f) => f,
None => bail!(
"a resolution records WHO decided: this decision is for {owner} — if that is you,\n \
cv task resolve {short} {answer} --from {owner}\n (or `export CV_ENDPOINT={owner}` once)",
owner = t.assignee.as_deref().unwrap_or("<you>"),
short = prefix(&id, 13),
answer = match &choice {
Some(c) => format!("--choice {c:?}"),
None if confirm => "--confirm".into(),
None => "--accept-default".into(),
}
),
};
let store = TaskStore::default_store();
let answer = task_ops::Answer::from_flags(choice, accept_default, confirm)?;
let report = task_ops::resolve(&store, &outcome.model, &from, token, &id, answer, note, provisional)?;
for w in report.replay_warnings.iter().chain(&report.warnings) {
eprintln!("⚠ {}", sanitize_line(w));
}
if let TaskEventKind::Resolved {
choice, provisional, ..
} = &report.event.kind
{
println!(
"✦ resolved {} → {} (by {}){}",
prefix(&id, 13),
sanitize_line(choice),
sanitize_line(&from),
if *provisional {
" — provisional: the decider can --confirm or veto"
} else {
""
}
);
}
Ok(())
}
TaskCmd::Split { id, dry_run, from } => {
let outcome = replay_loud()?;
let id = resolve(&outcome.model, &id)?.to_string();
let plan = task_ops::plan_split(&outcome.model, &id)?;
let plen = prefix_len(&outcome.model);
let t = &outcome.model.tasks[&id];
if plan.items.is_empty() {
println!(
"no leading-DECIDE notes on {} ({} note(s){})",
prefix(&id, plen),
t.notes.len(),
if plan.mid_text > 0 {
format!(
"; {} mention DECIDE mid-text — split takes notes that START with DECIDE",
plan.mid_text
)
} else {
String::new()
}
);
return Ok(());
}
println!(
"# {} DECIDE note(s) on {} — {}{}",
plan.items.len(),
prefix(&id, plen),
sanitize_line(&t.title),
if dry_run { " (dry run: nothing written)" } else { "" }
);
for (i, item) in plan.items.iter().enumerate() {
println!(
"{:>2}. {}{}",
i + 1,
sanitize_line(&item.title),
match &item.existing {
Some(c) => format!(" (already split → {})", prefix(c, plen)),
None => String::new(),
}
);
println!(" default: {}", sanitize_line(&item.default_choice));
for o in item.options.iter().skip(1) {
println!(" option: {}", sanitize_line(o));
}
println!(
" from note by {} at {}",
sanitize_line(&item.note_by),
fmt_local(item.note_ts, "%m-%d %H:%M")
);
}
if plan.mid_text > 0 {
println!(
"({} other note(s) mention DECIDE mid-text; not split — move the DECIDE to the front of a note to split it)",
plan.mid_text
);
}
if dry_run {
return Ok(());
}
let store = TaskStore::default_store();
let (created, warnings) = task_ops::run_split(&store, &outcome.model, &from_or_cv(from), &plan)?;
for w in &warnings {
eprintln!("⚠ {}", sanitize_line(w));
}
println!();
for (_, child) in &created {
println!(
"✦ decision {} posed for {}",
prefix(child, 13),
sanitize_line(t.assignee.as_deref().unwrap_or("-"))
);
}
println!(
"{} decision(s) created, each blocking {} · `cv task inbox {}` lists them first",
created.len(),
prefix(&id, plen),
sanitize_line(t.assignee.as_deref().unwrap_or("<assignee>"))
);
Ok(())
}
TaskCmd::Events {
since,
kind,
by,
not_by,
assignee,
task: task_id,
text,
} => {
let outcome = replay_loud()?;
let now = Utc::now();
let task_id = match task_id {
Some(p) => Some(resolve(&outcome.model, &p)?.to_string()),
None => None,
};
let f = EventFilter {
since: Since::parse(since.as_deref(), now)?,
kinds: kind
.as_deref()
.map(|k| k.split(',').map(task_ops::kind_tag).collect())
.unwrap_or_default(),
by,
not_by,
assignee,
task: task_id,
};
print_events(&outcome, &f, text)
}
TaskCmd::Watch {
assignee,
since,
not_by,
text,
} => {
let outcome = replay_loud()?;
let now = Utc::now();
let f = EventFilter {
since: Since::parse(since.as_deref(), now)?,
kinds: Vec::new(),
by: None,
not_by: not_by.or_else(task::default_endpoint),
assignee: Some(assignee),
task: None,
};
print_events(&outcome, &f, text)
}
TaskCmd::Serve {
bind,
assignee,
open,
token,
} => {
let who = assignee.or_else(task::default_endpoint);
super::task_serve::run(&bind, who, open, token)
}
TaskCmd::Debt { repo, json } => {
let outcome = replay_loud()?;
let hb = cv_core::task::verify::read_heartbeat(&task::tasks_dir());
if json {
let hb_ts = hb.as_ref().map(|h| h.ts);
let hb_interval = hb.as_ref().and_then(|h| h.interval_secs);
let now = Utc::now();
let json_rows: Vec<_> = task::debt(&outcome.model)
.iter()
.filter(|(group_repo, _)| match &repo {
Some(want) => group_repo.as_deref() == Some(want.as_path()),
None => true,
})
.flat_map(|(_, entries)| entries.clone())
.map(|e| {
let prov = cv_core::task::Provenance::awaiting_land(e.since, hb_ts, hb_interval, now);
let mut v = serde_json::to_value(&e).expect("DebtEntry serializes");
if let Some(obj) = v.as_object_mut() {
obj.insert(
"provenance".into(),
serde_json::to_value(prov).expect("Provenance serializes"),
);
}
v
})
.collect();
let awaiting: Vec<_> = task::awaiting_review(&outcome.model)
.into_iter()
.filter(|e| match &repo {
Some(want) => e.task.repo.as_deref() == Some(want.as_path()),
None => true,
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"debt": json_rows,
"awaiting_review": awaiting,
"suspects": hb.as_ref().map(|h| h.suspect_landed.clone()).unwrap_or_default(),
"verified_as_of": hb.as_ref().map(|h| h.ts),
"verify_warning": cv_core::task::verify::heartbeat_warning(hb.as_ref()),
}))?
);
return Ok(());
}
let report = task::DebtReport::compute(&outcome.model, hb.as_ref(), repo.as_deref());
let plen = prefix_len(&outcome.model);
let mut current: Option<&Option<std::path::PathBuf>> = None;
let now = Utc::now();
for row in &report.debt {
if current != Some(&row.repo) {
let name = row
.repo
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no repo)".into());
println!("{name}:");
current = Some(&row.repo);
}
let age = now.signed_duration_since(row.since);
println!(
" {} rev{} {} [{}] unlanded for {}h · {} — {}",
prefix(&row.id, plen),
row.revision,
sanitize_line(&row.branch),
row.state.as_str(),
age.num_hours(),
freshness_phrase(&row.provenance, now),
sanitize_line(&row.title)
);
for issue in &row.issues {
println!(" ⚠ {}", sanitize_line(issue));
}
}
if report.debt.is_empty() && report.suspects.is_empty() {
println!("✓ no unlanded reviewed work");
}
if !report.awaiting_review.is_empty() {
println!("awaiting review:");
for row in &report.awaiting_review {
println!(
" {} rev{} {} → {} waiting {} — {}",
prefix(&row.id, plen),
row.revision,
sanitize_line(&row.branch),
sanitize_line(row.reviewer.as_deref().unwrap_or("(reviewer unbound)")),
task::age_short(row.since, now),
sanitize_line(&row.title)
);
}
}
let suspect_prov = hb
.as_ref()
.map(|h| cv_core::task::Provenance::git_verified(h.ts, Some(h.ts), h.interval_secs, now));
for s in &report.suspects {
let fresh = suspect_prov
.as_ref()
.map(|p| format!(" · {}", freshness_phrase(p, now)))
.unwrap_or_default();
println!(
"⚠ SUSPECT {} rev{} {}{} — {}",
prefix(&s.task_id, plen),
s.revision,
sanitize_line(&s.detail),
fresh,
sanitize_line(&s.title)
);
}
if let Some(hb) = &hb {
println!(
"verified as of {}{}",
fmt_local(hb.ts, "%Y-%m-%d %H:%M:%S"),
hb.interval_secs.map(|i| format!(" (every {i}s)")).unwrap_or_default()
);
}
if let Some(w) = &report.verify_warning {
eprintln!("⚠ {}", sanitize_line(w));
}
Ok(())
}
TaskCmd::Stats { repo, json } => {
let outcome = replay_loud()?;
let hb = cv_core::task::verify::read_heartbeat(&task::tasks_dir());
let stats = task::FleetStats::compute(&outcome.model, hb.as_ref(), repo.as_deref());
if json {
println!("{}", serde_json::to_string_pretty(&stats)?);
} else {
render_stats(&stats);
}
Ok(())
}
}
}
fn print_events(outcome: &cv_core::task::ReplayOutcome, f: &EventFilter, text: bool) -> Result<()> {
let events = task_ops::select_events(outcome, f);
let plen = prefix_len(&outcome.model);
for ev in &events {
if text {
println!("{}", task_ops::event_text(ev, &outcome.model, plen));
} else {
println!("{}", serde_json::to_string(&task_ops::event_json(ev, &outcome.model))?);
}
}
match events.last() {
Some(last) => eprintln!("# {} event(s) · next: --since {}", events.len(), last.id),
None => eprintln!("# no events"),
}
Ok(())
}
fn freshness_phrase(p: &cv_core::task::Provenance, now: DateTime<Utc>) -> String {
use cv_core::task::Freshness;
match p.freshness {
Freshness::Unknown if p.source == cv_core::task::provenance::SOURCE_SELF_REPORT => "self-reported".into(),
Freshness::Unknown => "NEVER verified".into(),
Freshness::Fresh if p.source == cv_core::task::provenance::SOURCE_CHECKED => match p.observed_at {
Some(t) => format!("checked {} ago", task::age_short(t, now)),
None => "checked".into(),
},
Freshness::Fresh => match p.observed_at {
Some(t) => format!("observed {} ago", task::age_short(t, now)),
None => "verified".into(),
},
Freshness::Stale { .. } => match p.observed_at {
Some(t) => format!("last checked {} ago (stale)", task::age_short(t, now)),
None => "stale".into(),
},
}
}
fn describe_check(dc: &cv_core::task::DoneCheck) -> String {
use cv_core::task::DoneCheckKind;
let (label, target) = match &dc.kind {
DoneCheckKind::Cmd { cmd } => ("cmd", cmd.clone()),
DoneCheckKind::File { path } => ("file", path.display().to_string()),
DoneCheckKind::Http { url } => ("http", url.clone()),
};
format!("{label}: {target} → {}", dc.result)
}
fn receipts_line(r: &cv_core::task::ReviewReceipts) -> String {
let mark = |o: Option<bool>| match o {
Some(true) => "✓",
Some(false) => "✗",
None => "?",
};
let turns = r
.turns
.map(|t| format!("{t} turns"))
.unwrap_or_else(|| "? turns".into());
format!(
"saw change {}, ran checks {}, {}",
mark(r.saw_change),
mark(r.ran_checks),
turns
)
}
fn median_cell(secs: Option<i64>) -> String {
let now = Utc::now();
match secs {
None => "-".into(),
Some(s) => task::age_short(now - chrono::Duration::seconds(s.max(0)), now),
}
}
fn render_stats(stats: &cv_core::task::FleetStats) {
if stats.endpoints.is_empty() && stats.reviewers.is_empty() {
println!("(no task history yet)");
}
if !stats.endpoints.is_empty() {
println!("endpoints (as author/assignee):");
println!(
" {:24} {:>7} {:>8} {:>7} {:>7} {:>5} {:>6} {:>9} {:>9}",
"endpoint", "claimed", "proposed", "landed", "refuted", "live", "aband", "land-rate", "med-land"
);
for e in &stats.endpoints {
let terminal = e.landed + e.refuted + e.superseded;
let rate = if terminal == 0 {
"-".into()
} else {
format!("{}/{terminal}", e.landed)
};
println!(
" {:24} {:>7} {:>8} {:>7} {:>7} {:>5} {:>6} {:>9} {:>9}",
sanitize_line(&e.endpoint),
e.claimed,
e.proposed,
e.landed,
e.refuted,
e.unlanded,
e.abandoned_live,
rate,
median_cell(e.median_secs_to_land),
);
}
}
if !stats.reviewers.is_empty() {
println!("reviewers:");
println!(
" {:24} {:>8} {:>11} {:>9} {:>8} {:>10} {:>11}",
"reviewer", "verdicts", "pass/refute", "same-fam", "no-rcpt", "no-contact", "med-latency"
);
for r in &stats.reviewers {
println!(
" {:24} {:>8} {:>11} {:>9} {:>8} {:>10} {:>11}",
sanitize_line(&r.reviewer),
r.verdicts,
format!("{}/{}", r.passes, r.refutes),
r.same_family_passes,
r.no_receipts_passes,
r.no_contact_passes,
median_cell(r.median_review_latency_secs),
);
}
}
for f in &stats.families {
println!(
"family {}: {} reviews ({} cross-family, {} same-family, {} undetermined)",
sanitize_line(&f.family),
f.reviews_given,
f.cross_family,
f.same_family,
f.undetermined
);
}
match &stats.verified_as_of {
Some(ts) => println!("verified as of {}", fmt_local(*ts, "%Y-%m-%d %H:%M:%S")),
None => println!("verified: NEVER"),
}
if let Some(w) = &stats.verify_warning {
eprintln!("⚠ {}", sanitize_line(w));
}
println!("computed from observed events only; landed = git-verified");
}
fn cmd_verify(id: Option<String>, all: bool, fetch: bool, skip_landed: bool) -> Result<()> {
if id.is_none() && !all {
bail!("pass a task id or --all");
}
let store = TaskStore::default_store();
let ids: Option<Vec<String>> = match &id {
Some(prefix) => {
let outcome = replay_loud()?;
Some(vec![resolve(&outcome.model, prefix)?.to_string()])
}
None => None,
};
let opts = cv_core::task::verify::VerifyOptions {
fetch,
skip_landed,
..Default::default()
};
let (appended, warnings) = cv_core::task::verify::run_verify(&store, ids.as_deref(), &opts)?;
for w in &warnings {
eprintln!("⚠ {}", sanitize_line(w));
}
for ev in &appended {
println!("✦ observed {} on {}", ev.kind.tag(), prefix(&ev.task_id, 13));
}
if appended.is_empty() {
println!("(nothing new observed)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cv_core::task::{TaskProjection, TaskState};
fn proj(title: &str, last_ts: &str) -> TaskProjection {
TaskProjection {
task_id: "00000000-0000-7000-8000-000000000001".into(),
title: title.into(),
body: String::new(),
repo: None,
issue: None,
channel: "tasks".into(),
state: TaskState::Open,
assignee: Some("agent:a".into()),
opened_by: "h".into(),
opened_at: "2026-07-10T00:00:00Z".parse().unwrap(),
last_event_id: "e".into(),
last_ts: last_ts.parse().unwrap(),
revisions: Vec::new(),
notes: Vec::new(),
superseded_by: None,
abandoned_reason: None,
done_observed: None,
done_check: None,
tags: Vec::new(),
blocked_by: Vec::new(),
decision: None,
status: None,
}
}
#[test]
fn task_row_shows_age_and_strips_ansi() {
let t = proj("evil\u{1b}]0;pwn\u{7}title\u{1b}[31m!", "2026-07-13T00:00:00Z");
let now: DateTime<Utc> = "2026-07-16T00:00:00Z".parse().unwrap();
let row = task_row(&TaskRow::full(&t), now, 8, false);
assert!(row.contains(" 3d "), "age column from last_ts: {row}");
assert!(row.contains("eviltitle!"), "payload stripped: {row}");
assert!(!row.contains('\u{1b}'), "no ESC survives: {row}");
}
#[test]
fn blocked_rows_carry_the_marker_and_tsv_is_one_line_per_task() {
let mut t = proj("tab\tin\ntitle", "2026-07-13T00:00:00Z");
t.blocked_by = vec!["00000000-0000-7000-8000-000000000009".into()];
let now: DateTime<Utc> = "2026-07-16T00:00:00Z".parse().unwrap();
let row = task_row(&TaskRow::full(&t), now, 13, true);
assert!(row.contains("⊘ tab"), "{row}");
assert!(
row.starts_with("00000000-0000 "),
"prefix is sized by the caller: {row}"
);
let tsv = task_row_tsv(&TaskRow::full(&t), now);
assert_eq!(tsv.lines().count(), 1, "{tsv:?}");
let cells: Vec<&str> = tsv.split('\t').collect();
assert_eq!(cells.len(), 7, "{cells:?}");
assert_eq!(cells[0], t.task_id);
assert_eq!(cells[5], "tab in title");
assert_eq!(cells[6], "00000000-0000-7000-8000-000000000009");
}
#[test]
fn tags_parse_trimmed_and_deduplicated() {
assert_eq!(parse_tags(" a, b ,,a,c "), vec!["a", "b", "c"]);
assert!(parse_tags(", ,").is_empty());
}
#[test]
fn sweep_helpers_tell_branches_from_words_and_paths_from_handles() {
assert!(branch_like("sdk-ts-repair"));
assert!(branch_like("k-ran"));
assert!(branch_like("feature/x"));
assert!(branch_like("p3b1"));
assert!(!branch_like("fix"));
assert!(!branch_like("final"));
let toks = branch_tokens("one commit on branch `sdk-ts-repair` (see docs/plan.md).");
assert!(toks.contains("sdk-ts-repair"), "{toks:?}");
assert!(toks.contains("docs/plan.md"), "{toks:?}");
assert_eq!(
issue_path("claudesplosion/planning/SESSION-STATE.md"),
Some("claudesplosion/planning/SESSION-STATE.md")
);
assert_eq!(issue_path("#42"), None);
assert_eq!(issue_path("https://github.com/x/y/issues/4"), None);
assert_eq!(issue_path("memory/x.md"), Some("memory/x.md"));
}
#[test]
fn explicit_from_beats_everything_and_missing_identity_names_the_cure() {
assert_eq!(require_from(Some("agent:me".into())).unwrap(), "agent:me");
assert_eq!(from_or_cv(Some("agent:me".into())), "agent:me");
}
}
fn merged_branches(repo: &Path, main: &str) -> Result<BTreeSet<String>> {
let mut out = BTreeSet::new();
for extra in [&[][..], &["-r"][..]] {
let mut cmd = std::process::Command::new("git");
cmd.arg("-C")
.arg(repo)
.arg("branch")
.args(extra)
.arg("--format=%(refname:short)")
.arg("--merged")
.arg(main);
let o = cmd
.output()
.with_context(|| format!("running git branch --merged in {}", repo.display()))?;
if !o.status.success() {
bail!(
"git branch --merged {main} failed in {}: {}",
repo.display(),
String::from_utf8_lossy(&o.stderr).trim()
);
}
for line in String::from_utf8_lossy(&o.stdout).lines() {
let name = line.trim();
if name.is_empty() || name.ends_with("/HEAD") {
continue;
}
let short = name.split_once('/').map(|(_, rest)| rest).unwrap_or(name);
for n in [name, short] {
if n != main {
out.insert(n.to_string());
}
}
}
}
Ok(out)
}
fn has_ref(repo: &Path, name: &str) -> bool {
std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", "--quiet", name])
.output()
.is_ok_and(|o| o.status.success())
}
fn branch_like(name: &str) -> bool {
name.contains(['-', '/', '_', '.']) || name.chars().any(|c| c.is_ascii_digit()) || name.len() >= 8
}
fn branch_tokens(text: &str) -> BTreeSet<String> {
text.split(|c: char| !(c.is_alphanumeric() || matches!(c, '-' | '_' | '/' | '.')))
.map(|t| t.trim_matches(|c| matches!(c, '.' | '/')))
.filter(|t| t.len() >= 3)
.map(str::to_string)
.collect()
}
fn issue_path(issue: &str) -> Option<&str> {
let i = issue.trim();
let looks = !i.contains(char::is_whitespace)
&& !i.contains("://")
&& (i.contains('/') || Path::new(i).extension().is_some());
looks.then_some(i)
}
#[derive(serde::Serialize)]
struct SweepRow<'a> {
task_id: &'a str,
title: &'a str,
state: String,
reasons: Vec<String>,
}
fn cmd_sweep(repo: &Path, main: Option<String>, json: bool) -> Result<()> {
let repo = repo
.canonicalize()
.with_context(|| format!("repo {} not found", repo.display()))?;
let main = match main {
Some(m) => m,
None if has_ref(&repo, "main") => "main".into(),
None if has_ref(&repo, "master") => "master".into(),
None => bail!(
"{} has neither `main` nor `master`; pass --main <branch>",
repo.display()
),
};
let merged = merged_branches(&repo, &main)?;
let outcome = replay_loud()?;
let mut rows: Vec<SweepRow<'_>> = Vec::new();
for t in outcome.model.tasks.values() {
if t.state.is_terminal() || t.repo.as_deref().is_some_and(|r| r != repo) {
continue;
}
let mut reasons = Vec::new();
if let Some(p) = t.issue.as_deref().and_then(issue_path) {
let p = Path::new(p);
let bases: Vec<PathBuf> = if p.is_absolute() {
vec![PathBuf::new()]
} else {
let mut b: Vec<PathBuf> = Vec::new();
b.extend(t.repo.clone());
b.push(repo.clone());
b.extend(repo.parent().map(Path::to_path_buf));
b.extend(std::env::current_dir().ok());
b.dedup();
b
};
if !bases.iter().any(|b| b.join(p).exists()) {
let tried: Vec<String> = bases.iter().map(|b| b.join(p).display().to_string()).collect();
reasons.push(format!("issue path no longer exists ({})", tried.join(", ")));
}
}
let mentioned = branch_tokens(&format!("{}\n{}", t.title, t.body));
for b in mentioned
.iter()
.filter(|b| branch_like(b) && merged.contains(b.as_str()))
{
reasons.push(format!("names branch `{b}`, merged into {main}"));
}
if let Some(rev) = t.current_revision() {
if !rev.state.is_terminal() && merged.contains(&rev.revision.branch) {
reasons.push(format!(
"rev{} branch `{}` is merged into {main} (run `cv task verify`)",
rev.revision.n, rev.revision.branch
));
}
}
if !reasons.is_empty() {
rows.push(SweepRow {
task_id: &t.task_id,
title: &t.title,
state: task::effective_display(t),
reasons,
});
}
}
rows.sort_by(|a, b| a.task_id.cmp(b.task_id));
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if rows.is_empty() {
println!(
"nothing to sweep: no open task names a branch merged into {main} or a missing issue path ({} merged branch(es) observed)",
merged.len()
);
return Ok(());
}
let plen = prefix_len(&outcome.model);
println!(
"# probably done — {} candidate(s) in {} (observed: {} branch(es) merged into {main}); nothing was closed\n",
rows.len(),
repo.display(),
merged.len()
);
for r in &rows {
println!(
"{:<plen$} {:16} {}",
prefix(r.task_id, plen),
r.state,
sanitize_line(r.title)
);
for reason in &r.reasons {
println!("{:plen$} ↳ {}", "", sanitize_line(reason));
}
}
println!("\nclose what is really done: `cv task done <id> --observed <evidence>` · drop the rest: `cv task abandon <id> --reason …`");
Ok(())
}