speccoder_runtime/pipeline/
commit.rs1use 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(
35 dir: &Path,
36 feature: &str,
37 task: &str,
38 calls: &[serde_json::Value],
39 run_grants: &std::sync::Mutex<Vec<tools::policy::Grant>>,
40) {
41 let decided: Vec<approvals::Decided> = calls
45 .iter()
46 .filter(|c| c["approved_by"] == "person" || c["rejected_by"] == "person")
47 .filter(|c| !c["scope"].as_str().is_some_and(|s| s.ends_with("-grant")))
48 .filter_map(|c| {
49 let grant = tools::policy::Grant::of(c["tool"].as_str()?, &c["arguments"])?;
50 let by = serde_json::from_value::<approvals::Who>(c["approver"].clone())
51 .unwrap_or_else(|_| approvals::Who {
52 name: "unknown".to_owned(),
53 via: "inline".to_owned(),
54 basis: "asserted".to_owned(),
55 });
56 let approved = c["approved_by"] == "person";
57 Some(approvals::Decided {
58 verb: if approved { "approve" } else { "reject" }.to_owned(),
59 grant,
60 reason: c["rejection"].as_str().map(str::to_owned),
61 scope: c["scope"].as_str().unwrap_or("once").to_owned(),
62 note: c["note"].as_str().map(str::to_owned),
63 by,
64 at: std::time::SystemTime::now()
65 .duration_since(std::time::UNIX_EPOCH)
66 .map_or(0, |d| d.as_secs()),
67 })
68 })
69 .collect();
70 if decided.is_empty() {
71 return;
72 }
73 if let Ok(mut d) = approvals::Decisions::load(dir) {
74 for decision in decided {
75 match (decision.verb.as_str(), decision.scope.as_str()) {
76 ("approve", "task") => {
78 d.approve(feature, task, decision.grant.clone(), &decision.by);
79 if let Some(last) = d
80 .tasks
81 .get_mut(&format!("{feature}/{task}"))
82 .and_then(|t| t.decided.last_mut())
83 {
84 last.note.clone_from(&decision.note);
85 }
86 }
87 ("approve", "run") => {
89 if let Ok(mut g) = run_grants.lock()
90 && !g.contains(&decision.grant)
91 {
92 g.push(decision.grant.clone());
93 }
94 d.note(feature, task, decision);
95 }
96 _ => d.note(feature, task, decision),
97 }
98 }
99 let _ = d.save(dir);
100 }
101}
102
103pub(crate) fn add_usage(
105 a: Option<&serde_json::Value>,
106 b: Option<&serde_json::Value>,
107) -> Option<serde_json::Value> {
108 match (a, b) {
109 (None, b) => b.cloned(),
110 (a, None) => a.cloned(),
111 (Some(a), Some(b)) => {
112 let mut sum = serde_json::Map::new();
113 for key in [
114 "input_tokens",
115 "output_tokens",
116 "cache_read_tokens",
117 "cache_write_tokens",
118 ] {
119 let n = a[key].as_u64().unwrap_or(0) + b[key].as_u64().unwrap_or(0);
120 sum.insert(key.to_owned(), serde_json::json!(n));
121 }
122 Some(serde_json::Value::Object(sum))
123 }
124 }
125}
126
127pub(crate) fn commit_attempt(worktree: &Path, message: &str) -> Result<String, String> {
130 commit_attempt_as(worktree, message, false)
131}
132
133pub(crate) fn commit_attempt_as(
136 worktree: &Path,
137 message: &str,
138 allow_empty: bool,
139) -> Result<String, String> {
140 let g = git::Git::at(worktree);
141 let base = [
142 "-c",
143 "user.name=speccoder",
144 "-c",
145 "user.email=speccoder@users.noreply.invalid",
146 "-c",
147 "commit.gpgsign=false",
148 ];
149 let mut add = base.to_vec();
150 add.extend(["add", "-A"]);
151 g.run(&add).map_err(|e| e.0)?;
152 let mut commit = base.to_vec();
153 commit.extend(["commit", "-q", "--no-verify", "-m", message]);
154 if allow_empty {
155 commit.push("--allow-empty");
156 }
157 g.run(&commit).map_err(|e| e.0)?;
158 g.line(&["rev-parse", "HEAD"]).map_err(|e| e.0)
159}
160
161pub(crate) fn commit_subject(task: &str, title: &str) -> String {
163 let full = format!("speccoder: {task} {title}");
164 if full.chars().count() <= 72 {
165 return full;
166 }
167 let cut: String = full.chars().take(71).collect();
168 format!("{}…", cut.trim_end())
169}
170
171pub(crate) struct TickFacts<'a> {
173 pub(crate) feature: &'a str,
174 pub(crate) task: &'a str,
175 pub(crate) title: &'a str,
176 pub(crate) report: &'a speccoder_core::RunReport,
177 pub(crate) red: Option<speccoder_core::verify::RedClass>,
179 pub(crate) checklists_overridden: bool,
180}
181
182pub(crate) fn tick_message(f: &TickFacts<'_>) -> String {
187 let report = f.report;
188 let verdict_word = report
189 .verdict
190 .as_ref()
191 .map(super::outcome::verdict_word)
192 .unwrap_or_default();
193 let usage = add_usage(
195 add_usage(
196 report.usage.as_ref(),
197 report.acceptance.as_ref().and_then(|a| a.get("usage")),
198 )
199 .as_ref(),
200 report
201 .acceptance
202 .as_ref()
203 .and_then(|a| a.get("held_out"))
204 .and_then(|h| h.get("usage")),
205 );
206 let mut evidence = f
207 .red
208 .map(|c| format!("Red: {}\n", super::acceptance::class_word(c)))
209 .unwrap_or_default();
210 evidence.push_str(&held_out_trailer(report.acceptance.as_ref()));
211 evidence.push_str(&flaky_trailer(report.verification.as_ref()));
212 evidence.push_str(&scope_trailer(report.scope.as_ref()));
213 if let Some(strength) = &report.strength {
214 evidence.push_str(&speccoder_core::strength::trailers(strength));
215 }
216 if f.checklists_overridden {
217 evidence.push_str("Checklists: overridden\n");
218 }
219 format!(
220 "{}\n\n{}\n\nSpec-Task: {}/{}\nVerdict: {verdict_word}\n{evidence}Run: {}\n{}{}",
221 commit_subject(f.task, f.title),
222 f.title,
223 f.feature,
224 f.task,
225 report.run_id,
226 crate::attempts::usage_trailer(usage.as_ref()),
227 report
228 .mutation
229 .as_ref()
230 .and_then(crate::mutation::trailer)
231 .unwrap_or_default()
232 )
233}
234
235fn held_out_trailer(acceptance: Option<&serde_json::Value>) -> String {
238 let Some(h) = acceptance.and_then(|a| a.get("held_out")) else {
239 return String::new();
240 };
241 match h["state"].as_str() {
242 Some("on") => {
243 let kept = h["kept"].as_u64().unwrap_or(0);
244 format!(
245 "Held-Out: passed={kept}/{kept} manifest={}\n",
246 h["manifest"].as_str().unwrap_or_default()
247 )
248 }
249 Some("off") => "Held-Out: off\n".to_owned(),
250 Some("absent") => format!(
251 "Held-Out: absent={}\n",
252 h["reason"].as_str().unwrap_or("none_generated")
253 ),
254 _ => String::new(),
255 }
256}
257
258fn flaky_trailer(verification: Option<&speccoder_core::verify::VerifyReport>) -> String {
260 let names: Vec<&str> = verification
261 .into_iter()
262 .flat_map(|v| &v.checks)
263 .filter(|c| c.flaky)
264 .map(|c| c.name.as_str())
265 .collect();
266 if names.is_empty() {
267 String::new()
268 } else {
269 format!("Flaky: {}\n", names.join(", "))
270 }
271}
272
273fn scope_trailer(scope: Option<&speccoder_core::scope::ScopeFinding>) -> String {
275 let Some(outside) = scope.map(|s| &s.outside).filter(|o| !o.is_empty()) else {
276 return String::new();
277 };
278 let mut shown: Vec<&str> = outside.iter().take(10).map(String::as_str).collect();
279 let more = outside.len().saturating_sub(10);
280 let more = format!("… (+{more})");
281 if outside.len() > 10 {
282 shown.push(&more);
283 }
284 format!("Scope-Outside: {}\n", shown.join(", "))
285}
286
287pub(crate) fn set_aside_leftovers(
290 wt_root: &Path,
291 tmp: &Path,
292 feature: &str,
293 task: &str,
294) -> Result<String, String> {
295 crate::attempts::save(
296 wt_root,
297 tmp,
298 feature,
299 task,
300 &crate::attempts::Record {
301 run: None,
302 outcome: "interrupted",
303 reason: "the worktree had uncommitted changes when a run started (an interrupted run, or edits by hand); saved and set aside",
304 pending: None,
305 usage: None,
306 },
307 )
308 .and_then(|saved| crate::attempts::restore_head(wt_root, tmp).map(|()| saved.reference))
309 .map_err(|e| e.0)
310}