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 of this run, as
33/// if made with `speccoder task approve`, so a retry or a later attempt of
34/// the run is not asked again — and a later run is.
35///
36/// # Errors
37///
38/// The decisions file cannot be read or written: the attempt's decisions
39/// are not recorded (later attempts ask again), and the caller says so.
40pub(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    // Every decision a person made in this attempt, with who made it and how
49    // far it reaches; calls an earlier decision allowed (`*-grant`) are not
50    // new decisions.
51    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            // For the task: it joins the task's approvals, for later attempts.
86            ("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            // For this run: every later task of the run; the history only.
100            ("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
114/// Every model call of a task's run: its attempts, the judge-side calls
115/// that wrote and kept its tests (visible, held-out), and the monitor.
116pub(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
130/// Sums two token usage objects key by key.
131pub(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
154/// Commits everything in the worktree on its branch with speccoder's
155/// identity, without hooks; returns the commit id. Never pushes.
156pub(crate) fn commit_attempt(worktree: &Path, message: &str) -> Result<String, String> {
157    commit_attempt_as(worktree, message, false)
158}
159
160/// Commits exactly `paths` (worktree-relative: added, changed or removed)
161/// with speccoder's identity — nothing else the worktree holds; returns the
162/// commit id. Never pushes.
163pub(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
194/// [`commit_attempt`]; `allow_empty` records a verdict whose work touched no
195/// tracked file (in place, the tick lives in a git-ignored `tasks.md`).
196pub(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    // Never a file copied from the checkout ([worktree] include), whatever
211    // the ignore rules say; the index is checked again before the commit.
212    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
227/// `speccoder: T004 <title>` cut to 72 characters on a char boundary.
228pub(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
237/// What a tick's commit message is made of.
238pub(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    /// The kind of red the acceptance tests showed, when they did.
244    pub(crate) red: Option<speccoder_core::verify::RedClass>,
245    pub(crate) checklists_overridden: bool,
246    /// More trailers (a bug task's `Bug:`, `Acceptance:`, `Fixes`).
247    pub(crate) extra: String,
248    /// The story's scenarios as worded now: what `Scenarios:` digests.
249    pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
250}
251
252/// The scenarios a verified tick's acceptance tests cite, once each in the
253/// order cited, each as a digest of its id and wording: `Scenarios:
254/// US3-1@3f9a0c1d2e4b, …` (the id kept for a reader where it is one plain
255/// word; an OpenSpec scenario's name is not). The story's checkpoint no
256/// longer has to judge them while they are worded the same.
257pub(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    // A task of a story claims exactly what it turned green on the oracle.
264    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
303/// A scenario, digested: 12 hex of SHA-256 over its id, a NUL and its
304/// wording with runs of whitespace as one space.
305pub(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
310/// The commit message of a ticked task: subject, title, and the trailers
311/// `report` reads back (`Spec-Task`, `Verdict`, `Regression-Only`, `Red`, `Scenarios`, `Flaky`,
312/// `Scope-Outside`, `Checklists`, `Held-Out`, `Acceptance-Strength`,
313/// `Ruling`, `Run`, `Usage`, `Mutation`).
314pub(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    // An external agent made the attempt: its preset from the configuration
352    // (`acp` for one of the person's own), never the name it gave itself.
353    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    // Commands the sandbox confined ran without asking: how many, on the tick.
362    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
387/// `Held-Out: passed=<k>/<k> manifest=sha256:<hex>`, `absent=<reason>` or
388/// `off`; nothing for a task without acceptance tests.
389fn 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
410/// `Flaky: <checks>` for checks that passed only when run again.
411fn 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
425/// `Scope-Outside: <paths>` (the first ten, then a count).
426fn 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
439/// Leftovers of an interrupted run (or edits by hand) saved under an attempt
440/// ref and set aside, so a run starts from the branch tip; the ref.
441pub(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    /// The value of trailer `key` in `message`, by exact key (`Acceptance:` is
502    /// not `Acceptance-Strength:`).
503    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        // A set frozen at review has the same one form.
529        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}