1use serde_json::Value;
5use speccoder_core::scope::ScopeFinding;
6use speccoder_core::verify::Verdict;
7use speccoder_core::{CommandStatus, RunReport};
8
9use crate::progress;
10
11use super::{add_usage, monitor_summary};
12
13pub(crate) fn scope_of(report: &RunReport, title: &str) -> ScopeFinding {
16 let exempt: Vec<String> = report
17 .acceptance
18 .as_ref()
19 .and_then(|a| a["files"].as_array())
20 .into_iter()
21 .flatten()
22 .filter_map(|f| f.as_str().map(str::to_owned))
23 .collect();
24 let changed: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
25 let named = speccoder_core::scope::named_paths(title);
26 speccoder_core::scope::finding(&named, &changed, &exempt)
27}
28
29pub(crate) fn saved_outcome(
31 report: &RunReport,
32 attempt_status: &str,
33 suspected: bool,
34) -> (String, String) {
35 let outcome = match attempt_status {
36 _ if suspected => "suspected_gaming".to_owned(),
37 "completed" | "integrity" => "verification_failed".to_owned(),
38 other => other.to_owned(),
39 };
40 let reason = match &report.verdict {
41 _ if suspected => monitor_summary(report.monitor.as_ref()),
42 Some(Verdict::Failed { reasons }) => failed_because(report, reasons),
43 _ if attempt_status == "incomplete" && report.reason.is_none() => {
45 incomplete_because(report.model_report.as_ref())
46 }
47 _ => report
48 .reason
49 .clone()
50 .unwrap_or_else(|| attempt_status.to_owned()),
51 };
52 (outcome, reason)
53}
54
55pub(crate) fn status_and_message(
57 report: &RunReport,
58 attempt_status: &str,
59 suspected: bool,
60) -> (CommandStatus, String) {
61 match (&report.verdict, attempt_status) {
62 (_, "over_strict") => (
63 CommandStatus::NeedsHuman,
64 format!(
65 "the checks passed, but {}; the attempt is held, not ticked — rule on it (`speccoder task rule {task} --tests-right --reason …`), regenerate the tests (`speccoder run --task {task} --fresh-acceptance`) or accept it (`speccoder task accept {task}`)",
66 report.reason.clone().unwrap_or_default(),
67 task = report.task
68 ),
69 ),
70 (_, "acceptance_weak") => (
71 CommandStatus::NeedsHuman,
72 format!(
73 "the checks passed, but the {}; the attempt is saved, not ticked",
74 report.reason.clone().unwrap_or_default()
75 ),
76 ),
77 _ if suspected => (
78 CommandStatus::NeedsHuman,
79 format!(
80 "the checks passed, but the gaming monitor suspects: {}; the attempt is saved, not ticked — review it, then `speccoder task accept` or run again",
81 monitor_summary(report.monitor.as_ref())
82 ),
83 ),
84 (_, "incomplete") => (
85 CommandStatus::NeedsHuman,
86 format!(
87 "attempt incomplete — {}{}",
88 incomplete_because(report.model_report.as_ref()),
89 if report.changes.is_empty() {
90 "; no files changed"
91 } else {
92 ""
93 }
94 ),
95 ),
96 (None, "collection_unknown") => (
97 CommandStatus::NeedsHuman,
98 format!(
99 "{}; the attempt is saved, not ticked",
100 report.reason.clone().unwrap_or_default()
101 ),
102 ),
103 (_, "tick_failed") => (
106 CommandStatus::NeedsHuman,
107 format!(
108 "the attempt passed but was not recorded ({}); its work is saved{} — fix the cause, then run the task again",
109 report.reason.clone().unwrap_or_default(),
110 report
111 .saved_attempt
112 .as_deref()
113 .map(|r| format!(" under `{r}`"))
114 .unwrap_or_default()
115 ),
116 ),
117 (Some(Verdict::Failed { reasons }), _)
120 if matches!(
121 report.progress,
122 Some(speccoder_core::story::Progress::StoryOpen { .. })
123 ) =>
124 {
125 (
126 CommandStatus::NeedsHuman,
127 format!(
128 "the story is still open — {}",
129 failed_because(report, reasons)
130 ),
131 )
132 }
133 (Some(Verdict::Failed { reasons }), _) => (
134 CommandStatus::VerificationFailed,
135 format!("verdict: failed — {}", failed_because(report, reasons)),
136 ),
137 (Some(v), _) => (CommandStatus::Ok, format!("verdict: {}", verdict_word(v))),
138 (None, "needs_approval") if report.pending.as_ref().is_some_and(Value::is_object) => (
140 CommandStatus::NeedsHuman,
141 format!(
142 "attempt needs_approval: {}",
143 crate::tools::policy::unasked(
144 report.pending.as_ref().unwrap_or(&Value::Null),
145 &format!(
146 "approve it (`speccoder task approve {}`), or run with prompts (without `--no-prompt`) to be asked",
147 report.task
148 ),
149 )
150 ),
151 ),
152 (
153 None,
154 "blocked" | "needs_approval" | "budget_exhausted" | "denied" | "interrupted"
155 | "held_out" | "intent_changed" | "judge_changed" | "checkout_changed" | "git_changed",
156 ) => (
157 CommandStatus::NeedsHuman,
158 format!(
159 "attempt {attempt_status}: {}",
160 report
161 .reason
162 .clone()
163 .or_else(|| report
164 .pending
165 .as_ref()
166 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
167 .unwrap_or_default()
168 ),
169 ),
170 (None, _) => (
171 CommandStatus::RunFailed,
172 format!(
173 "attempt failed: {}",
174 report.reason.clone().unwrap_or_default()
175 ),
176 ),
177 }
178}
179
180fn failed_because(report: &RunReport, reasons: &[String]) -> String {
183 let checks = report
184 .verification
185 .as_ref()
186 .map(|v| v.checks.as_slice())
187 .unwrap_or_default();
188 reasons
189 .iter()
190 .map(|r| {
191 let named = r
192 .strip_prefix("check `")
193 .and_then(|n| n.split_once('`'))
194 .map(|(n, _)| n);
195 match named
196 .and_then(|n| checks.iter().find(|c| c.name == n))
197 .and_then(speccoder_core::retry::first_cause)
198 {
199 Some(cause) => format!("{r}: {cause}"),
200 None => r.clone(),
201 }
202 })
203 .collect::<Vec<_>>()
204 .join("; ")
205}
206
207fn incomplete_because(model_report: Option<&Value>) -> String {
210 let report = model_report.unwrap_or(&Value::Null);
211 let notes: Vec<&str> = report["notes"]
212 .as_array()
213 .into_iter()
214 .flatten()
215 .filter_map(Value::as_str)
216 .map(str::trim)
217 .filter(|n| !n.is_empty())
218 .collect();
219 let said = if notes.is_empty() {
220 report["summary"]
221 .as_str()
222 .unwrap_or_default()
223 .trim()
224 .to_owned()
225 } else {
226 notes.join(" ")
227 };
228 if said.is_empty() {
229 return "the model says it did not finish".to_owned();
230 }
231 let said = crate::approvals::present::escape(&said.replace('\n', " "));
232 let cut: String = said.chars().take(400).collect();
233 let more = if cut.len() < said.len() { "…" } else { "" };
234 format!("the model says: “{cut}{more}”")
235}
236
237pub(crate) fn verdict_word(v: &Verdict) -> String {
239 serde_json::to_value(v)
240 .ok()
241 .and_then(|v| v["verdict"].as_str().map(str::to_owned))
242 .unwrap_or_default()
243}
244
245pub(crate) fn finished_event(
247 task: &str,
248 report: &RunReport,
249 attempt_status: &str,
250 suspected: bool,
251 message: String,
252) -> progress::Event {
253 let total = add_usage(
254 report.usage.as_ref(),
255 report.acceptance.as_ref().and_then(|a| a.get("usage")),
256 );
257 let tokens = |k: &str| total.as_ref().and_then(|u| u[k].as_u64()).unwrap_or(0);
258 progress::Event::TaskFinished {
259 task: task.to_owned(),
260 outcome: if suspected {
263 "suspected_gaming".to_owned()
264 } else if attempt_status == "completed" {
265 report
266 .verdict
267 .as_ref()
268 .map_or_else(|| attempt_status.to_owned(), verdict_word)
269 } else {
270 attempt_status.to_owned()
271 },
272 message,
273 run: (!report.run_id.is_empty()).then(|| report.run_id.clone()),
274 input_tokens: tokens("input_tokens"),
275 output_tokens: tokens("output_tokens"),
276 cache_read_tokens: tokens("cache_read_tokens"),
277 cache_write_tokens: tokens("cache_write_tokens"),
278 usd: None,
279 regression: report.regression.clone(),
280 }
281}
282
283pub(crate) fn bare_report(
285 feature: &str,
286 task: &str,
287 worktree: &str,
288 attempt_status: &str,
289) -> RunReport {
290 RunReport {
291 run_id: String::new(),
292 feature: feature.to_owned(),
293 task: task.to_owned(),
294 worktree: worktree.to_owned(),
295 attempt_status: attempt_status.to_owned(),
296 model_report: None,
297 reason: None,
298 pending: None,
299 tool_calls: Vec::new(),
300 changes: Vec::new(),
301 verification: None,
302 verdict: None,
303 usage: None,
304 integrity: Vec::new(),
305 ticked: false,
306 commit: None,
307 acceptance: None,
308 earlier: Vec::new(),
309 retry_stop: None,
310 saved_attempt: None,
311 set_aside: None,
312 notices: Vec::new(),
313 agent: None,
314 monitor: None,
315 mutation: None,
316 samples: Vec::new(),
317 scope: None,
318 attestation: None,
319 instructions: Vec::new(),
320 strength: None,
321 bug: None,
322 collection: None,
323 regression: None,
324 judged: None,
325 progress: None,
326 context: None,
327 }
328}
329
330pub(crate) fn undemonstrated_why(acceptance: Option<&super::PreparedAcceptance>) -> String {
333 match acceptance {
334 None => "the spec has no acceptance examples to test it against".to_owned(),
335 Some(a) => match (a.report["skipped"].as_str(), a.report["note"].as_str()) {
336 (Some(s), _) => format!("no acceptance tests ({s})"),
337 (None, Some(note)) => match a.report["excerpt"].as_str().and_then(|e| e.lines().next())
338 {
339 Some(first) => format!("{note}: {first}"),
340 None => note.to_owned(),
341 },
342 (None, None) => "its acceptance tests already pass before any change".to_owned(),
343 },
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use serde_json::json;
351
352 #[test]
353 fn a_failed_check_says_what_it_said_first() {
354 use speccoder_core::verify::{CheckResult, CheckStatus, VerifyReport};
355 let mut report = bare_report("f", "T007", "w", "completed");
356 report.verification = Some(VerifyReport {
357 root: String::new(),
358 tier: "fast".to_owned(),
359 checks: vec![CheckResult {
360 name: "acceptance".to_owned(),
361 argv: vec!["python3".to_owned()],
362 status: CheckStatus::Failed,
363 exit_code: Some(1),
364 duration_ms: 0,
365 junit: false,
366 tests: None,
367 stdout_tail: String::new(),
368 stderr_tail: "/usr/bin/python3: No module named pytest\n".to_owned(),
369 truncated: false,
370 note: None,
371 flaky: false,
372 }],
373 comparison: None,
374 passed: false,
375 });
376 let reasons = [
377 "check `acceptance` failed".to_owned(),
378 "regression: t is failed".to_owned(),
379 ];
380 assert_eq!(
381 failed_because(&report, &reasons),
382 "check `acceptance` failed: /usr/bin/python3: No module named pytest; regression: t is failed"
383 );
384 }
385
386 #[test]
387 fn an_incomplete_attempt_quotes_what_the_model_says_is_missing() {
388 let notes =
389 json!({ "summary": "s", "notes": ["", "pytest is \u{1b}[31mmissing", "so I stopped"] });
390 assert_eq!(
391 incomplete_because(Some(¬es)),
392 "the model says: “pytest is ␛[31mmissing so I stopped”"
393 );
394 let summary = json!({ "summary": "only the plumbing\nso far", "notes": [] });
395 assert_eq!(
396 incomplete_because(Some(&summary)),
397 "the model says: “only the plumbing so far”"
398 );
399 assert_eq!(incomplete_because(None), "the model says it did not finish");
400 let long = json!({ "notes": ["é".repeat(500)] });
401 assert!(incomplete_because(Some(&long)).ends_with("é…”"));
402 }
403}