Skip to main content

speccoder_runtime/pipeline/
commit.rs

1//! Commit messages, trailers and the records a finished attempt leaves.
2
3use std::path::Path;
4
5use crate::{approvals, git, tools};
6
7/// The monitor's findings in one line: `kind: evidence; …`.
8pub(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
32/// Inline "for this task" approvals become task decisions, as if made with
33/// `speccoder task approve`, so a retry or a later attempt is not asked again.
34pub(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    // Every decision a person made in this attempt, with who made it and how
42    // far it reaches; calls an earlier decision allowed (`*-grant`) are not
43    // new decisions.
44    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                // For the task: it joins the task's approvals, for later attempts.
77                ("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                // For this run: every later task of the run; the history only.
88                ("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
103/// Sums two token usage objects key by key.
104pub(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
127/// Commits everything in the worktree on its branch with speccoder's
128/// identity, without hooks; returns the commit id. Never pushes.
129pub(crate) fn commit_attempt(worktree: &Path, message: &str) -> Result<String, String> {
130    commit_attempt_as(worktree, message, false)
131}
132
133/// [`commit_attempt`]; `allow_empty` records a verdict whose work touched no
134/// tracked file (in place, the tick lives in a git-ignored `tasks.md`).
135pub(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
161/// `speccoder: T004 <title>` cut to 72 characters on a char boundary.
162pub(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
171/// What a tick's commit message is made of.
172pub(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    /// The kind of red the acceptance tests showed, when they did.
178    pub(crate) red: Option<speccoder_core::verify::RedClass>,
179    pub(crate) checklists_overridden: bool,
180}
181
182/// The commit message of a ticked task: subject, title, and the trailers
183/// `report` reads back (`Spec-Task`, `Verdict`, `Red`, `Flaky`,
184/// `Scope-Outside`, `Checklists`, `Held-Out`, `Acceptance-Strength`,
185/// `Ruling`, `Run`, `Usage`, `Mutation`).
186pub(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    // Attempts and the judge-side acceptance calls together.
194    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
235/// `Held-Out: passed=<k>/<k> manifest=sha256:<hex>`, `absent=<reason>` or
236/// `off`; nothing for a task without acceptance tests.
237fn 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
258/// `Flaky: <checks>` for checks that passed only when run again.
259fn 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
273/// `Scope-Outside: <paths>` (the first ten, then a count).
274fn 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
287/// Leftovers of an interrupted run (or edits by hand) saved under an attempt
288/// ref and set aside, so a run starts from the branch tip; the ref.
289pub(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}