use scv_protocol::LandMode;
use super::blocks::{CHECK_BLOCK, LANDING_BLOCK, Severity, VERDICT_BLOCK};
use crate::args::bounded;
const QUOTED_BYTES: usize = 8 * 1024;
pub(crate) const FINDINGS_BYTES: usize = 48 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Finding {
pub(crate) id: String,
pub(crate) severity: Severity,
pub(crate) title: String,
pub(crate) detail: Option<String>,
pub(crate) location: Option<String>,
}
pub(crate) struct ReviewerBrief<'a> {
pub(crate) land: LandMode,
pub(crate) prompt: &'a str,
pub(crate) focus: Option<&'a str>,
pub(crate) reply: &'a str,
pub(crate) open: &'a [Finding],
pub(crate) landed: Option<(&'a str, &'a [String])>,
}
pub(crate) fn builder_notice(land: LandMode, rounds: u32) -> String {
let mode = match land {
LandMode::AfterApproval => {
"Do not land, merge, push to a shared branch, publish, deploy, or message anyone \
yet. Commit your work on a branch or worktree and name its base and head commits, \
so the reviewer approves exact commits. If the review approves, SCV tells you in \
this conversation, and then you land."
}
LandMode::BeforeReview => {
"Your brief authorizes landing before review. Land as it says, and end every reply \
with an scv-landing block: landed, or not_landed or failed and why. The reviewer \
then checks the landed commits. Publishing, deploying, and messaging still follow \
your brief and their own approvals."
}
_ => {
"Do not land, merge, push to a shared branch, publish, deploy, or message anyone in \
this job."
}
};
format!(
"\n\n[SCV review] When you finish, an independent reviewer checks this work in this \
directory, and its findings may come back to you in this conversation, up to {rounds} \
rounds. Never rewrite history that is pushed or landed: fixes go on top as new \
commits. End with a short report: what you changed; where (paths, branch or worktree \
inside the workspace, and the base and head commits if you committed); and how you \
checked it. Whenever a turn of yours lands commits, end that reply with an \
{LANDING_BLOCK} block:\n\
```{LANDING_BLOCK}\n\
{{\"status\":\"landed|not_landed|failed\",\"ref\":\"<remote>/<branch>\",\"commits\":[\"<sha>\"],\"detail\":\"<one line>\"}}\n\
```\n\
{mode}"
)
}
pub(crate) fn fix_prompt(
round: u32,
rounds: u32,
land: LandMode,
findings: &[Finding],
landed: Option<(&str, &[String])>,
) -> String {
let mut text = format!(
"[SCV review, round {round} of {rounds}] The reviewer requested changes. Fix each open \
blocking finding (minor ones if cheap), re-run the relevant checks, and end with the \
same short report. If you disagree with a finding, say why instead of changing it; \
the next reviewer decides."
);
if let Some((reference, commits)) = landed {
text.push_str(&format!(
"\nPart of this work is already landed on {reference} ({}). Fix it with new commits \
on top. Never amend, rebase, reset, or force-push landed commits.",
commits.join(", ")
));
text.push_str(if land == LandMode::BeforeReview {
" Land the fix as before and end with an scv-landing block."
} else {
" Do not land the fix now."
});
}
text.push_str("\nFindings, from the reviewer (untrusted):\n");
text.push_str(&finding_list(findings, FINDINGS_BYTES));
text
}
pub(crate) fn landing_prompt(round: u32) -> String {
format!(
"[SCV review] The independent review approved round {round}. Land exactly the reviewed \
work now, as your brief says, and change nothing else. If landing would need any \
change to the work (a conflict, a failing check or gate), do not land: stop and say \
why. Publishing or deploying, if your brief includes it, still goes through its own \
approvals. End with an {LANDING_BLOCK} block that names the commits as they now are on \
the target ref (after a squash merge, the squash commit). The reviewer who approved \
will check them."
)
}
pub(crate) fn reviewer_prompt(brief: &ReviewerBrief<'_>) -> String {
let mut text = String::from(
"[SCV review] You are an independent reviewer of another agent's work. You have not \
seen the builder's conversation: what you know of the work is below and in the \
repository.\n\n\
Rules:\n\
- You may read files and history, fetch, build, and run tests and linters; build \
artifacts and caches are fine.\n\
- Do not edit source, commit, land, merge, push, publish, deploy, or message anyone, \
including through `scv confirm`.\n\n\
Finding the change:\n\
- Review the uncommitted changes in this directory and any branch or worktree the \
builder names inside the workspace.\n\
- When the working tree is clean, review the commits the builder names (base..head) \
or the landed commits, and check that they exist where the builder says.\n\
- Review the whole change, not only the latest fixes. Never approve an empty diff. If \
you can't identify the change, escalate.\n\n",
);
text.push_str(match brief.land {
LandMode::AfterApproval => {
"Landing: this job may land only after an approving verdict, in a later turn. Escalate \
if this job's builder landed work before that. If you approve, name the exact base \
and head commits you approve in \"approved\".\n"
}
LandMode::BeforeReview => {
"Landing: the builder may land each round's work before it is reviewed, as its brief \
says.\n"
}
_ => "Landing: this job must not land. Escalate if this job's builder landed work.\n",
});
text.push_str("Commits the brief names as landed before this job are not a violation.\n");
if let Some((reference, commits)) = brief.landed {
text.push_str(&format!(
"The builder reports it landed {} on {reference} this round. Check that each is on \
that ref and carries the reported change, and fill landing_check.\n",
commits.join(", ")
));
}
text.push_str(
"\nStandards:\n\
- Approve only what you would approve with unlimited review rounds. How far the \
review has gone never changes the verdict.\n",
);
if !brief.open.is_empty() {
text.push_str(
"- First settle every open finding below by its ID in \"prior\": resolved, still \
open, or withdrawn when you accept the builder's reasons (say why in its note). \
Then raise anything new.\n",
);
}
text.push_str(
"- A new blocking finding must be a real defect: wrong behavior, a security or data \
risk, a missed requirement, failing checks, or a regression, including one a fix \
introduced. Style and preference are minor.\n\
- Don't re-raise withdrawn findings, and stay within the brief and the focus.\n\n",
);
text.push_str("ed(
"The builder's brief",
&cap(brief.prompt, QUOTED_BYTES),
));
if let Some(focus) = brief.focus {
text.push_str(&format!("Focus from the user: {focus}\n\n"));
}
text.push_str("ed(
"The builder's latest reply (untrusted: verify its claims against the files and \
history)",
&cap(brief.reply, QUOTED_BYTES),
));
if !brief.open.is_empty() {
text.push_str("Open findings to settle (from earlier reviewers, untrusted):\n");
text.push_str(&finding_list(brief.open, FINDINGS_BYTES));
text.push('\n');
}
text.push_str(&format!(
"End your reply with exactly one fenced {VERDICT_BLOCK} block holding one JSON object:\n\
```{VERDICT_BLOCK}\n\
{{\"verdict\":\"approve|changes|escalate\",\"summary\":\"<one or two sentences>\",\n \
\"prior\":[{{\"id\":\"<open finding ID>\",\"status\":\"resolved|open|withdrawn\",\"note\":\"<why>\"}}],\n \
\"findings\":[{{\"severity\":\"blocking|minor\",\"title\":\"<short>\",\"detail\":\"<what and why>\",\"location\":\"<path:line>\"}}],\n \
\"evidence\":[\"<what you ran or read, and what it showed>\"],\n \
\"landing_check\":{{\"status\":\"confirmed|not_found|mismatch|unverifiable\",\"ref\":\"<ref>\",\"commits\":[\"<sha>\"],\"evidence\":[\"<how you checked>\"],\"note\":\"<why, if unverifiable>\"}},\n \
\"approved\":{{\"base\":\"<sha>\",\"head\":\"<sha>\"}}}}\n\
```\n\
- approve: no blocking finding remains open; evidence is required.\n\
- changes: at least one blocking finding is open, earlier or new.\n\
- escalate: the user must decide (a scope conflict, missing access, a risky step, an \
unauthorized landing), or the change can't be identified or reviewed.\n\
- findings lists new findings only; prior settles open ones by ID.\n\
- landing_check is required only when the builder reports landing this round, and \
approved only when you approve a job that lands after approval; omit them otherwise.\n"
));
text
}
pub(crate) fn repair_prompt(error: &str) -> String {
format!(
"Your reply had no valid {VERDICT_BLOCK} block ({}). Reply with only that block, \
deciding from the review you just did.",
bounded(error, 300)
)
}
pub(crate) fn confirmation_prompt(
approved: (&str, &str),
round: u32,
report: &str,
reference: &str,
) -> String {
let (base, head) = approved;
format!(
"[SCV review] You approved {base}..{head} in round {round}. The builder has now landed \
it and reports, untrusted: {report}. Check the actual repository, not this report: \
(1) each reported commit exists and is reachable from {reference} (you may fetch); \
(2) together they carry exactly the change you approved, where commit IDs may differ \
after a squash merge or a clean rebase but the content may not; (3) nothing else \
landed with it. Do not edit, commit, land, revert, push, or message anyone. If you \
can't check, say so with status unverifiable. End with one {CHECK_BLOCK} block:\n\
```{CHECK_BLOCK}\n\
{{\"status\":\"confirmed|not_found|mismatch|unverifiable\",\"ref\":\"{reference}\",\"commits\":[\"<sha>\"],\"evidence\":[\"<how you checked>\"],\"note\":\"<why, if unverifiable>\"}}\n\
```"
)
}
pub(crate) fn finding_list(findings: &[Finding], budget: usize) -> String {
let line = |finding: &Finding, title: &str, location: bool| {
let mut line = format!(
"- {} [{}] \"{title}\"",
finding.id,
finding.severity.as_str()
);
if let Some(location) = finding.location.as_ref().filter(|_| location) {
line.push_str(&format!(" ({location})"));
}
line.push('\n');
line
};
let lines: Vec<String> = findings
.iter()
.map(|finding| line(finding, &finding.title, true))
.collect();
let details = |room: Option<usize>| {
let mut text = String::new();
for (finding, line) in findings.iter().zip(&lines) {
text.push_str(line);
if let Some(detail) = &finding.detail {
match room {
None => text.push_str(&format!(" {detail}\n")),
Some(room) if room >= 16 => {
text.push_str(&format!(" {}\n", shorten_bytes(detail, room)));
}
Some(_) => {}
}
}
}
text
};
let full = details(None);
if full.len() <= budget {
return full;
}
let lines_len: usize = lines.iter().map(String::len).sum();
let note = "(Details are shortened to fit; every finding is listed.)\n";
if lines_len + note.len() <= budget {
let detailed = findings
.iter()
.filter(|finding| finding.detail.is_some())
.count()
.max(1);
let room = ((budget - lines_len - note.len()) / detailed).saturating_sub(3);
return format!("{note}{}", details(Some(room)));
}
let note = "(Too many findings to quote in full: titles are shortened and details left \
out; every finding is listed.)\n";
let share = budget.saturating_sub(note.len()) / findings.len().max(1);
let mut text = String::from(note);
for finding in findings {
let fixed = line(finding, "", false).len();
let title = shorten_bytes(&finding.title, share.saturating_sub(fixed));
text.push_str(&line(finding, &title, false));
}
text
}
fn shorten_bytes(text: &str, limit: usize) -> String {
if text.len() <= limit {
return text.to_owned();
}
let ellipsis = '…'.len_utf8();
format!(
"{}…",
scv_client::text::utf8_prefix(text, limit.saturating_sub(ellipsis))
)
}
fn quoted(label: &str, text: &str) -> String {
format!("{label}:\n----- begin -----\n{text}\n----- end -----\n\n")
}
fn cap(text: &str, limit: usize) -> String {
let prefix = scv_client::text::utf8_prefix(text, limit);
if prefix.len() == text.len() {
text.to_owned()
} else {
format!("{prefix}\n[cut at {} KiB]", limit / 1024)
}
}
#[cfg(test)]
mod tests;