Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod hook;
mod init;
mod mcp;
mod ponytail;
mod review;
mod run;
mod uninstall;
mod update;
Expand Down Expand Up @@ -54,6 +55,7 @@ pub enum Commands {
Ponytail(ponytail::PonytailArgs),
Caveman(caveman::CavemanArgs),
Claim(claim::ClaimArgs),
Review(review::ReviewArgs),
}

impl Commands {
Expand All @@ -76,6 +78,7 @@ impl Commands {
Self::Ponytail(cmd) => cmd.run(),
Self::Caveman(cmd) => cmd.run(),
Self::Claim(cmd) => cmd.run(),
Self::Review(cmd) => cmd.run(),
}
}
}
154 changes: 154 additions & 0 deletions src/cli/review.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use clap::{Args, Subcommand};

/// Multi-agent review consensus: finders submit findings, agentflare verifies
/// citations against the diff, dedups, and tags CONFIRMED/UNIQUE/DISPUTED/
/// UNVERIFIED. Stored in ~/.agentflare/agentflare.db.
#[derive(Args)]
pub struct ReviewArgs {
#[command(subcommand)]
pub action: ReviewAction,
}

#[derive(Subcommand)]
pub enum ReviewAction {
/// Submit a finder's findings (JSON array of {file,line,message,severity?,category?})
/// from --file or stdin. Replaces this agent's prior findings for the round.
Submit {
/// Review round id (default: current branch name).
#[arg(long)]
pr: Option<String>,
/// Finder name (default: detected agent).
#[arg(long)]
agent: Option<String>,
/// JSON file of findings (default: read stdin).
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
repo: Option<String>,
},
/// Verify, dedup, and tag all submitted findings into one consensus report.
Consensus {
#[arg(long)]
pr: Option<String>,
/// Diff base ref (default: master).
#[arg(long)]
base: Option<String>,
/// Diff head ref (default: HEAD).
#[arg(long)]
head: Option<String>,
#[arg(long)]
repo: Option<String>,
/// Emit JSON instead of markdown.
#[arg(long)]
json: bool,
},
/// List the raw submitted findings for a round.
List {
#[arg(long)]
pr: Option<String>,
#[arg(long)]
repo: Option<String>,
},
/// Drop all submitted findings for a round.
Clear {
#[arg(long)]
pr: Option<String>,
#[arg(long)]
repo: Option<String>,
},
}

impl ReviewArgs {
pub fn run(self) {
let conn = match crate::db::open() {
Ok(c) => c,
Err(e) => fail(format!("cannot open ledger: {e}")),
};
match self.action {
ReviewAction::Submit { pr, agent, file, repo } => {
let repo = require_repo(repo);
let pr = resolve_pr(pr);
let agent = agent.unwrap_or_else(crate::review::submitter_name);
let raw = match &file {
Some(p) => std::fs::read_to_string(p).unwrap_or_else(|e| fail(format!("cannot read {}: {e}", p.display()))),
None => read_stdin(),
};
let findings: Vec<crate::review::Finding> = serde_json::from_str(&raw)
.unwrap_or_else(|e| fail(format!("invalid findings JSON: {e}")));
match crate::review::submit(&conn, &repo, &pr, &agent, &findings, crate::claims::now()) {
Ok(n) => println!("submitted {n} finding(s) as {agent} for {repo}#{pr}"),
Err(e) => fail(format!("submit failed: {e}")),
}
}
ReviewAction::Consensus { pr, base, head, repo, json } => {
let repo = require_repo(repo);
let pr = resolve_pr(pr);
let findings = crate::review::load(&conn, &repo, &pr)
.unwrap_or_else(|e| fail(format!("load failed: {e}")));
let diff = crate::review::compute_diff(base.as_deref(), head.as_deref())
.unwrap_or_else(|e| fail(e));
let changed = crate::review::changed_lines(&diff);
let items = crate::review::consensus(&findings, &changed);
if json {
println!("{}", serde_json::to_string_pretty(&items).unwrap_or_default());
} else {
println!("{}", crate::review::render_markdown(&items));
}
}
ReviewAction::List { pr, repo } => {
let repo = require_repo(repo);
let pr = resolve_pr(pr);
match crate::review::load(&conn, &repo, &pr) {
Ok(fs) if fs.is_empty() => println!("no findings for {repo}#{pr}"),
Ok(fs) => {
for sf in fs {
println!("{} {}:{} {}", sf.agent, sf.finding.file, sf.finding.line, sf.finding.message);
}
}
Err(e) => fail(format!("list failed: {e}")),
}
}
ReviewAction::Clear { pr, repo } => {
let repo = require_repo(repo);
let pr = resolve_pr(pr);
match crate::review::clear(&conn, &repo, &pr) {
Ok(n) => println!("cleared {n} finding(s) for {repo}#{pr}"),
Err(e) => fail(format!("clear failed: {e}")),
}
}
}
}
}

/// Round id: explicit --pr, else the current branch name.
fn resolve_pr(explicit: Option<String>) -> String {
explicit.filter(|s| !s.is_empty()).unwrap_or_else(|| {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fail("could not determine round — pass --pr".to_string()))
})
}

fn require_repo(explicit: Option<String>) -> String {
crate::claims::resolve_repo(explicit)
.unwrap_or_else(|| fail("could not determine repo — run in a git repo or pass --repo owner/name".to_string()))
}

fn read_stdin() -> String {
use std::io::Read;
let mut s = String::new();
if std::io::stdin().read_to_string(&mut s).is_err() {
fail("failed to read findings from stdin".to_string());
}
s
}

fn fail(msg: String) -> ! {
eprintln!("review: {msg}");
std::process::exit(1);
}
1 change: 1 addition & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub fn open() -> rusqlite::Result<Connection> {
restrict(&path, 0o600);
tune(&conn)?;
crate::claims::migrate(&conn)?;
crate::review::migrate(&conn)?;
Ok(conn)
}

Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod mise_install;
mod optimize;
mod paths;
mod pricing;
mod review;
mod rollup;
mod rule_text;
mod shell;
Expand Down
134 changes: 134 additions & 0 deletions src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,47 @@ struct ClaimListRequest {
all_repos: bool,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewSubmitRequest {
#[schemars(description = "Findings, each {file, line, message, severity?, category?}")]
findings: Vec<serde_json::Value>,
#[schemars(description = "Review round id (default: current branch)")]
#[serde(default)]
pr: Option<String>,
#[schemars(description = "Finder name (default: detected agent)")]
#[serde(default)]
agent: Option<String>,
#[schemars(description = "Repo key owner/name (default: origin remote)")]
#[serde(default)]
repo: Option<String>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewConsensusRequest {
#[schemars(description = "Review round id (default: current branch)")]
#[serde(default)]
pr: Option<String>,
#[schemars(description = "Diff base ref (default: master)")]
#[serde(default)]
base: Option<String>,
#[schemars(description = "Diff head ref (default: HEAD)")]
#[serde(default)]
head: Option<String>,
#[schemars(description = "Repo key owner/name (default: origin remote)")]
#[serde(default)]
repo: Option<String>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReviewRoundRequest {
#[schemars(description = "Review round id (default: current branch)")]
#[serde(default)]
pr: Option<String>,
#[schemars(description = "Repo key owner/name (default: origin remote)")]
#[serde(default)]
repo: Option<String>,
}

#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
struct ArtifactPublishRequest {
#[schemars(description = "Display name of the artifact")]
Expand Down Expand Up @@ -792,6 +833,99 @@ impl AgentflareMcp {
Ok((conn, repo))
}

#[tool(description = "Submit a finder's review findings for a round (each finding is {file, line, message, severity?, category?}). Replaces this finder's prior findings for the round. Call from each reviewing agent, then call review_consensus to verify + dedup + tag.")]
fn review_submit(
&self,
Parameters(ReviewSubmitRequest { findings, pr, agent, repo }): Parameters<ReviewSubmitRequest>,
) -> Result<String, ErrorData> {
let conn = Self::claim_db()?;
let repo = Self::resolve_repo_or_err(repo)?;
let pr = Self::resolve_round(pr)?;
let agent = agent.filter(|s| !s.is_empty()).unwrap_or_else(crate::review::submitter_name);
let parsed: Vec<crate::review::Finding> = findings
.into_iter()
.map(serde_json::from_value)
.collect::<Result<_, _>>()
.map_err(|e| ErrorData::invalid_params(format!("invalid finding: {e}"), None))?;
let n = crate::review::submit(&conn, &repo, &pr, &agent, &parsed, crate::claims::now())
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(serde_json::json!({ "submitted": n, "repo": repo, "pr": pr, "agent": agent }).to_string())
}

#[tool(description = "Verify all submitted findings for a round against the git diff (base...head), dedup overlapping ones, and tag each CONFIRMED/UNIQUE/DISPUTED/UNVERIFIED. Returns the ranked consensus items.")]
fn review_consensus(
&self,
Parameters(ReviewConsensusRequest { pr, base, head, repo }): Parameters<ReviewConsensusRequest>,
) -> Result<String, ErrorData> {
let conn = Self::claim_db()?;
let repo = Self::resolve_repo_or_err(repo)?;
let pr = Self::resolve_round(pr)?;
let findings = crate::review::load(&conn, &repo, &pr)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let diff = crate::review::compute_diff(base.as_deref(), head.as_deref())
.map_err(|e| ErrorData::invalid_params(e, None))?;
let changed = crate::review::changed_lines(&diff);
let items = crate::review::consensus(&findings, &changed);
Ok(serde_json::json!({
"repo": repo,
"pr": pr,
"items": items,
"markdown": crate::review::render_markdown(&items),
})
.to_string())
}

#[tool(description = "List the raw submitted findings for a review round (before consensus).")]
fn review_list(
&self,
Parameters(ReviewRoundRequest { pr, repo }): Parameters<ReviewRoundRequest>,
) -> Result<String, ErrorData> {
let conn = Self::claim_db()?;
let repo = Self::resolve_repo_or_err(repo)?;
let pr = Self::resolve_round(pr)?;
let findings = crate::review::load(&conn, &repo, &pr)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let rows: Vec<serde_json::Value> = findings
.iter()
.map(|sf| serde_json::json!({ "agent": sf.agent, "file": sf.finding.file, "line": sf.finding.line, "message": sf.finding.message, "severity": sf.finding.severity }))
.collect();
Ok(serde_json::to_string_pretty(&rows).unwrap_or_default())
}

#[tool(description = "Drop all submitted findings for a review round.")]
fn review_clear(
&self,
Parameters(ReviewRoundRequest { pr, repo }): Parameters<ReviewRoundRequest>,
) -> Result<String, ErrorData> {
let conn = Self::claim_db()?;
let repo = Self::resolve_repo_or_err(repo)?;
let pr = Self::resolve_round(pr)?;
let n = crate::review::clear(&conn, &repo, &pr)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(serde_json::json!({ "cleared": n, "repo": repo, "pr": pr }).to_string())
}

fn resolve_repo_or_err(repo: Option<String>) -> Result<String, ErrorData> {
crate::claims::resolve_repo(repo).ok_or_else(|| {
ErrorData::invalid_params("could not determine repo — run in a git repo or pass repo=owner/name", None)
})
}

/// Review round id: explicit `pr`, else the current branch name.
fn resolve_round(pr: Option<String>) -> Result<String, ErrorData> {
if let Some(pr) = pr.filter(|s| !s.is_empty()) {
return Ok(pr);
}
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| ErrorData::invalid_params("could not determine round — pass pr", None))
}

fn gateway_db_path() -> std::path::PathBuf {
dirs::data_local_dir().unwrap_or_else(std::env::temp_dir).join("agentflare").join("gateway.db")
}
Expand Down
Loading
Loading