1use std::path::Path;
4
5use crate::{approvals, git, tools};
6
7pub(crate) fn monitor_summary(monitor: Option<&serde_json::Value>) -> String {
9 monitor
10 .and_then(|m| m["findings"].as_array())
11 .map(|f| {
12 f.iter()
13 .map(|x| {
14 format!(
15 "{}: {}",
16 x["kind"].as_str().unwrap_or("other"),
17 x["evidence"]
18 .as_str()
19 .unwrap_or_default()
20 .split_whitespace()
21 .collect::<Vec<_>>()
22 .join(" ")
23 )
24 })
25 .collect::<Vec<_>>()
26 .join("; ")
27 })
28 .filter(|s| !s.is_empty())
29 .unwrap_or_else(|| "no details given".to_owned())
30}
31
32pub(crate) fn record_inline_approvals(
41 dir: &Path,
42 feature: &str,
43 task: &str,
44 calls: &[serde_json::Value],
45 run_grants: &std::sync::Mutex<Vec<tools::policy::Object>>,
46 run: &str,
47) -> Result<(), String> {
48 let decided: Vec<approvals::Decided> = calls
52 .iter()
53 .filter(|c| c["approved_by"] == "person" || c["rejected_by"] == "person")
54 .filter(|c| !c["scope"].as_str().is_some_and(|s| s.ends_with("-grant")))
55 .filter_map(|c| {
56 let object: tools::policy::Object = serde_json::from_value(c["object"].clone()).ok()?;
57 let by = serde_json::from_value::<approvals::Who>(c["approver"].clone())
58 .unwrap_or_else(|_| approvals::Who {
59 name: "unknown".to_owned(),
60 via: "inline".to_owned(),
61 client: None,
62 basis: "asserted".to_owned(),
63 });
64 let approved = c["approved_by"] == "person";
65 Some(approvals::Decided {
66 verb: if approved { "approve" } else { "reject" }.to_owned(),
67 object: Some(object),
68 run: Some(run.to_owned()),
69 reason: c["rejection"].as_str().map(str::to_owned),
70 scope: c["scope"].as_str().unwrap_or("once").to_owned(),
71 note: c["note"].as_str().map(str::to_owned),
72 by,
73 at: std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .map_or(0, |d| d.as_secs()),
76 })
77 })
78 .collect();
79 if decided.is_empty() {
80 return Ok(());
81 }
82 let mut d = approvals::Decisions::load(dir)?;
83 for decision in decided {
84 match (decision.verb.as_str(), decision.scope.as_str()) {
85 ("approve", "task") => {
87 let Some(object) = decision.object.clone() else {
88 continue;
89 };
90 d.approve(feature, task, object, Some(run), &decision.by);
91 if let Some(last) = d
92 .tasks
93 .get_mut(&format!("{feature}/{task}"))
94 .and_then(|t| t.decided.last_mut())
95 {
96 last.note.clone_from(&decision.note);
97 }
98 }
99 ("approve", "run") => {
101 if let (Ok(mut g), Some(object)) = (run_grants.lock(), &decision.object)
102 && !g.contains(object)
103 {
104 g.push(object.clone());
105 }
106 d.note(feature, task, decision);
107 }
108 _ => d.note(feature, task, decision),
109 }
110 }
111 d.save(dir)
112}
113
114pub(crate) fn all_usage(report: &speccoder_core::RunReport) -> Option<serde_json::Value> {
117 [
118 report.acceptance.as_ref().and_then(|a| a.get("usage")),
119 report
120 .acceptance
121 .as_ref()
122 .and_then(|a| a.get("held_out"))
123 .and_then(|h| h.get("usage")),
124 report.monitor.as_ref().and_then(|m| m.get("usage")),
125 ]
126 .into_iter()
127 .fold(report.usage.clone(), |acc, u| add_usage(acc.as_ref(), u))
128}
129
130pub(crate) fn add_usage(
132 a: Option<&serde_json::Value>,
133 b: Option<&serde_json::Value>,
134) -> Option<serde_json::Value> {
135 match (a, b) {
136 (None, b) => b.cloned(),
137 (a, None) => a.cloned(),
138 (Some(a), Some(b)) => {
139 let mut sum = serde_json::Map::new();
140 for key in [
141 "input_tokens",
142 "output_tokens",
143 "cache_read_tokens",
144 "cache_write_tokens",
145 ] {
146 let n = a[key].as_u64().unwrap_or(0) + b[key].as_u64().unwrap_or(0);
147 sum.insert(key.to_owned(), serde_json::json!(n));
148 }
149 Some(serde_json::Value::Object(sum))
150 }
151 }
152}
153
154pub(crate) fn commit_attempt(worktree: &Path, message: &str) -> Result<String, String> {
157 commit_attempt_as(worktree, message, false)
158}
159
160pub(crate) fn commit_paths(
164 worktree: &Path,
165 message: &str,
166 paths: &[String],
167 allow_empty: bool,
168) -> Result<String, String> {
169 let g = git::Git::at(worktree);
170 let base = [
171 "-c",
172 "user.name=speccoder",
173 "-c",
174 "user.email=speccoder@users.noreply.invalid",
175 "-c",
176 "commit.gpgsign=false",
177 ];
178 g.run(&["reset", "-q"]).map_err(|e| e.0)?;
179 if !paths.is_empty() {
180 let mut add = base.to_vec();
181 add.extend(["add", "-A", "--"]);
182 add.extend(paths.iter().map(String::as_str));
183 g.run(&add).map_err(|e| e.0)?;
184 }
185 let mut commit = base.to_vec();
186 commit.extend(["commit", "-q", "--no-verify", "-m", message]);
187 if allow_empty {
188 commit.push("--allow-empty");
189 }
190 g.run(&commit).map_err(|e| e.0)?;
191 g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
192}
193
194pub(crate) fn commit_attempt_as(
197 worktree: &Path,
198 message: &str,
199 allow_empty: bool,
200) -> Result<String, String> {
201 let g = git::Git::at(worktree);
202 let base = [
203 "-c",
204 "user.name=speccoder",
205 "-c",
206 "user.email=speccoder@users.noreply.invalid",
207 "-c",
208 "commit.gpgsign=false",
209 ];
210 let excluded = crate::include::pathspecs(worktree);
213 let mut add = base.to_vec();
214 add.extend(["add", "-A", "--", ":/"]);
215 add.extend(excluded.iter().map(String::as_str));
216 g.run(&add).map_err(|e| e.0)?;
217 crate::include::check_staged(worktree)?;
218 let mut commit = base.to_vec();
219 commit.extend(["commit", "-q", "--no-verify", "-m", message]);
220 if allow_empty {
221 commit.push("--allow-empty");
222 }
223 g.run(&commit).map_err(|e| e.0)?;
224 g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
225}
226
227pub(crate) fn commit_subject(task: &str, title: &str) -> String {
229 let full = format!("speccoder: {task} {title}");
230 if full.chars().count() <= 72 {
231 return full;
232 }
233 let cut: String = full.chars().take(71).collect();
234 format!("{}…", cut.trim_end())
235}
236
237pub(crate) struct TickFacts<'a> {
239 pub(crate) feature: &'a str,
240 pub(crate) task: &'a str,
241 pub(crate) title: &'a str,
242 pub(crate) report: &'a speccoder_core::RunReport,
243 pub(crate) red: Option<speccoder_core::verify::RedClass>,
245 pub(crate) checklists_overridden: bool,
246 pub(crate) extra: String,
248 pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
250}
251
252pub(crate) fn scenarios_trailer(
258 acceptance: Option<&serde_json::Value>,
259 progress: Option<&speccoder_core::story::Progress>,
260 scenarios: &[speccoder_core::spec::ScenarioRef],
261) -> String {
262 let mut cited: Vec<&str> = Vec::new();
263 if let Some(speccoder_core::story::Progress::Verified { turned }) = progress {
265 cited.extend(turned.iter().map(String::as_str));
266 }
267 for t in acceptance
268 .and_then(|a| a["tests"].as_array())
269 .into_iter()
270 .flatten()
271 {
272 for s in t["scenarios"].as_array().into_iter().flatten() {
273 if let Some(s) = s.as_str().filter(|s| !cited.contains(s)) {
274 cited.push(s);
275 }
276 }
277 }
278 let plain = |id: &str| {
279 !id.is_empty()
280 && id
281 .chars()
282 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
283 };
284 let marked: Vec<String> = cited
285 .iter()
286 .filter_map(|id| scenarios.iter().find(|s| s.id == *id))
287 .map(|s| {
288 let d = scenario_digest(&s.id, &s.text);
289 if plain(&s.id) {
290 format!("{}@{d}", s.id)
291 } else {
292 d
293 }
294 })
295 .collect();
296 if marked.is_empty() {
297 String::new()
298 } else {
299 format!("Scenarios: {}\n", marked.join(", "))
300 }
301}
302
303pub(crate) fn scenario_digest(id: &str, text: &str) -> String {
306 let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
307 crate::integrity::sha256_hex(format!("{id}\0{words}").as_bytes())[..12].to_owned()
308}
309
310pub(crate) fn tick_message(f: &TickFacts<'_>) -> String {
315 let report = f.report;
316 let verdict_word = report
317 .verdict
318 .as_ref()
319 .map(super::outcome::verdict_word)
320 .unwrap_or_default();
321 let usage = all_usage(report);
322 let mut evidence = match (&report.regression, verdict_word.as_str()) {
323 (Some(r), "regression_only") => format!(
324 "{}: {}\n",
325 speccoder_core::regression::TRAILER,
326 speccoder_core::regression::trailer(&r.reason)
327 ),
328 _ => String::new(),
329 };
330 evidence.push_str(
331 &f.red
332 .map(|c| format!("Red: {}\n", super::acceptance::class_word(c)))
333 .unwrap_or_default(),
334 );
335 evidence.push_str(&held_out_trailer(report.acceptance.as_ref()));
336 if verdict_word == "verified" {
337 evidence.push_str(&scenarios_trailer(
338 report.acceptance.as_ref(),
339 report.progress.as_ref(),
340 f.scenarios,
341 ));
342 }
343 evidence.push_str(&flaky_trailer(report.verification.as_ref()));
344 evidence.push_str(&scope_trailer(report.scope.as_ref()));
345 if let Some(strength) = &report.strength {
346 evidence.push_str(&speccoder_core::strength::trailers(strength));
347 }
348 if f.checklists_overridden {
349 evidence.push_str("Checklists: overridden\n");
350 }
351 if let Some(a) = &report.agent {
354 let preset: String = a
355 .preset
356 .chars()
357 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
358 .collect();
359 evidence.push_str(&format!("Executor: {preset}\n"));
360 }
361 let contained = report
363 .tool_calls
364 .iter()
365 .filter(|c| c["contained"] == true)
366 .count();
367 if contained > 0 {
368 evidence.push_str(&format!("Contained: {contained}\n"));
369 }
370 evidence.push_str(&f.extra);
371 format!(
372 "{}\n\n{}\n\nSpec-Task: {}/{}\nVerdict: {verdict_word}\n{evidence}Run: {}\n{}{}",
373 commit_subject(f.task, f.title),
374 f.title,
375 f.feature,
376 f.task,
377 report.run_id,
378 crate::attempts::usage_trailer(usage.as_ref()),
379 report
380 .mutation
381 .as_ref()
382 .and_then(crate::mutation::trailer)
383 .unwrap_or_default()
384 )
385}
386
387fn held_out_trailer(acceptance: Option<&serde_json::Value>) -> String {
390 let Some(h) = acceptance.and_then(|a| a.get("held_out")) else {
391 return String::new();
392 };
393 match h["state"].as_str() {
394 Some("on") => {
395 let kept = h["kept"].as_u64().unwrap_or(0);
396 format!(
397 "Held-Out: passed={kept}/{kept} manifest={}\n",
398 h["manifest"].as_str().unwrap_or_default()
399 )
400 }
401 Some("off") => "Held-Out: off\n".to_owned(),
402 Some("absent") => format!(
403 "Held-Out: absent={}\n",
404 h["reason"].as_str().unwrap_or("none_generated")
405 ),
406 _ => String::new(),
407 }
408}
409
410fn flaky_trailer(verification: Option<&speccoder_core::verify::VerifyReport>) -> String {
412 let names: Vec<&str> = verification
413 .into_iter()
414 .flat_map(|v| &v.checks)
415 .filter(|c| c.flaky)
416 .map(|c| c.name.as_str())
417 .collect();
418 if names.is_empty() {
419 String::new()
420 } else {
421 format!("Flaky: {}\n", names.join(", "))
422 }
423}
424
425fn scope_trailer(scope: Option<&speccoder_core::scope::ScopeFinding>) -> String {
427 let Some(outside) = scope.map(|s| &s.outside).filter(|o| !o.is_empty()) else {
428 return String::new();
429 };
430 let mut shown: Vec<&str> = outside.iter().take(10).map(String::as_str).collect();
431 let more = outside.len().saturating_sub(10);
432 let more = format!("… (+{more})");
433 if outside.len() > 10 {
434 shown.push(&more);
435 }
436 format!("Scope-Outside: {}\n", shown.join(", "))
437}
438
439pub(crate) fn set_aside_leftovers(
442 wt_root: &Path,
443 tmp: &Path,
444 feature: &str,
445 task: &str,
446) -> Result<String, String> {
447 crate::attempts::save(
448 wt_root,
449 tmp,
450 feature,
451 task,
452 &crate::attempts::Record {
453 run: None,
454 outcome: crate::controls::SET_ASIDE,
455 reason: "the worktree had uncommitted changes when a run started (an interrupted run, or edits by hand); saved and set aside — `speccoder task restore` keeps them, `task drop` discards them",
456 pending: None,
457 usage: None,
458 story_open: &[],
459 own_test: None,
460 },
461 )
462 .and_then(|saved| crate::attempts::restore_head(wt_root, tmp).map(|()| saved.reference))
463 .map_err(|e| e.0)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn an_agent_s_tick_names_its_executor_by_preset() {
472 let mut report = crate::pipeline::outcome::bare_report("f", "T001", "w", "completed");
473 let facts = |report: &speccoder_core::RunReport| {
474 tick_message(&TickFacts {
475 feature: "f",
476 task: "T001",
477 title: "Add it",
478 report,
479 red: None,
480 checklists_overridden: false,
481 extra: String::new(),
482 scenarios: &[],
483 })
484 };
485 assert_eq!(trailer(&facts(&report), "Executor"), None);
486 report.agent = Some(speccoder_core::AgentAttempt {
487 agent: "Copilot\nVerdict: verified".to_owned(),
488 preset: "copilot\nVerdict: forged".to_owned(),
489 mode: None,
490 decides_alone: false,
491 known: true,
492 unasked: 0,
493 tally: speccoder_core::PermissionTally::default(),
494 plan: Vec::new(),
495 });
496 let m = facts(&report);
497 assert_eq!(trailer(&m, "Executor"), Some("copilotVerdictforged"));
498 assert_eq!(m.matches("Verdict:").count(), 1, "{m}");
499 }
500
501 fn trailer<'m>(message: &'m str, key: &str) -> Option<&'m str> {
504 message.lines().find_map(|l| {
505 let (k, v) = l.split_once(':')?;
506 (k == key).then(|| v.trim())
507 })
508 }
509
510 #[test]
511 fn trailers_are_read_by_their_exact_key() {
512 let m = "speccoder: T001 fix\n\nSpec-Task: 007-bugfixes/T001\nVerdict: verified\nAcceptance-Strength: 1.00 near-miss=1/1\nAcceptance: bug_confirmation manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada\nBug: fixed (standing_speckit) triad=sha256:t\nFixes: #42\n";
513 assert_eq!(
514 trailer(m, "Acceptance"),
515 Some("bug_confirmation manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada")
516 );
517 assert_eq!(
518 trailer(m, "Acceptance-Strength"),
519 Some("1.00 near-miss=1/1")
520 );
521 assert_eq!(
522 trailer(m, "Bug"),
523 Some("fixed (standing_speckit) triad=sha256:t")
524 );
525 assert_eq!(trailer(m, "Spec-Task"), Some("007-bugfixes/T001"));
526 assert_eq!(trailer(m, "Fixes"), Some("#42"));
527 assert_eq!(trailer(m, "Acceptance-Str"), None);
528 let review = "speccoder: T005 x\n\nAcceptance-Strength: 0.80 near-miss=0/1\nAcceptance: review manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada Lovelace\n";
530 assert_eq!(
531 trailer(review, "Acceptance"),
532 Some("review manifest=sha256:m approval=sha256:a wording=sha256:w by=Ada Lovelace")
533 );
534 assert_eq!(
535 trailer(review, "Acceptance-Strength"),
536 Some("0.80 near-miss=0/1")
537 );
538 }
539}