Skip to main content

speccoder_runtime/
controls.rs

1//! A person's controls over single tasks: `accept`, `skip`,
2//! `nudge` — and `cost`, what the work used.
3//!
4//! What a person decides is recorded where speccoder's other durable facts
5//! are: an acceptance is a commit on `speccoder/<feature>` (`Verdict:
6//! attested` — a person's word, never shown as evidence); a skip is a ref
7//! under `refs/speccoder/skipped/`; a nudge is a task note in the state
8//! directory that rides in the attempt's journaled input.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use speccoder_core::{
14    CommandData, CommandResult, CommandStatus, ControlReport, CostReport, TaskCost, Usage,
15};
16
17use crate::git::Git;
18
19/// Where skips live.
20pub const SKIPPED: &str = "refs/speccoder/skipped";
21
22const FS: char = '\u{1f}';
23const RS: char = '\u{1e}';
24
25/// One line: trailers and subjects never span lines.
26fn one_line(text: &str) -> String {
27    text.split_whitespace().collect::<Vec<_>>().join(" ")
28}
29
30/// The person, named as every approval names them: `<name> (asserted)`.
31fn person(root: &Path) -> String {
32    format!("{} (asserted)", crate::approvals::who_in(root))
33}
34
35/// A person's decision on a task as an attestation: one
36/// `task-verification`, verdict `attested` or `skipped`, no summary. A
37/// line for the message; empty when `[attest]` is off.
38fn attest_decision(
39    repo: &Path,
40    project: &Path,
41    task: &speccoder_core::snapshot::Task,
42    unit: (&str, &str),
43    record: (&str, &str),
44    decider: (&str, &str),
45) -> String {
46    let config = crate::verify::find_config(project)
47        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
48        .unwrap_or_default();
49    let Ok(run_config) = crate::substrate::run_config::parse(&config) else {
50        return String::new();
51    };
52    if !run_config.attest.enabled {
53        return String::new();
54    }
55    let adapter = crate::format::at(project);
56    let format = format!("{:?}", adapter.kind()).to_lowercase();
57    let requirements = adapter.requirement_ids(project, unit.1);
58    let tick = crate::attest::Tick {
59        repo,
60        config: &config,
61        attest: &run_config.attest,
62        format: &format,
63        feature: unit.0,
64        feature_dir: unit.1,
65        task: &task.id,
66        title: &task.title,
67        line_hash: &task.line_hash,
68        section: task.story.as_deref(),
69        section_text: "",
70        requirements: &requirements,
71        commit: record.0,
72        verdict: record.1,
73        sandbox: serde_json::Value::Null,
74        executor: serde_json::Value::Null,
75        model: serde_json::Value::Null,
76    };
77    let a = crate::attest::record_decision(&tick, decider.0, decider.1);
78    match (a["state"].as_str(), a["reason"].as_str()) {
79        (Some("signed"), _) => format!(
80            "; attested: signed ({})",
81            a["signer"].as_str().unwrap_or("?")
82        ),
83        (Some(state), Some(why)) => format!("; attestation {state}: {why}"),
84        (Some(state), None) => format!("; attestation {state}"),
85        _ => String::new(),
86    }
87}
88
89fn done(command: &str, report: ControlReport, message: &str) -> CommandResult {
90    CommandResult::new(command, CommandStatus::Ok, message)
91        .with_data(CommandData::Control(Box::new(report)))
92}
93
94/// The selected feature and the project root of the user's checkout.
95fn locate(
96    command: &str,
97    path: &Path,
98    feature: Option<&str>,
99) -> Result<(String, PathBuf), Box<CommandResult>> {
100    let inspected = crate::inspect(path, feature);
101    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
102        return Err(Box::new(CommandResult::carried(command, &inspected)));
103    };
104    let Some(f) = snapshot.feature.as_ref() else {
105        return Err(Box::new(CommandResult::new(
106            command,
107            CommandStatus::SelectionNeeded,
108            inspected.message(),
109        )));
110    };
111    Ok((f.feature.name.clone(), PathBuf::from(&snapshot.root)))
112}
113
114/// The feature's open tasks as `report` sees them (branch first).
115fn open_tasks(root: &Path, feature: &str) -> Vec<String> {
116    match crate::status::report(root, Some(feature)).data() {
117        Some(CommandData::Report(r)) => r
118            .tasks
119            .iter()
120            .filter(|t| matches!(t.state.as_str(), "ready" | "blocked" | "skipped"))
121            .map(|t| t.id.clone())
122            .collect(),
123        _ => Vec::new(),
124    }
125}
126
127// ── accept ─────────────────────────────────────────────────────────────────
128
129/// `speccoder task accept T --reason R`: a person attests the task is
130/// done. speccoder ticks it and commits `Verdict: attested`.
131pub fn accept_with(
132    path: &Path,
133    feature: Option<&str>,
134    task: &str,
135    reason: &str,
136    state: &Path,
137) -> CommandResult {
138    accept_in(path, feature, task, reason, state, false)
139}
140
141/// [`accept_with`], in the person's checkout when `in_place` (`accept
142/// --in-place`): committed with the in-place trailers, then ticked.
143pub fn accept_in(
144    path: &Path,
145    feature: Option<&str>,
146    task: &str,
147    reason: &str,
148    state: &Path,
149    in_place: bool,
150) -> CommandResult {
151    let options = crate::RunOptions {
152        in_place,
153        ..crate::RunOptions::default()
154    };
155    crate::pipeline::in_session("accept", path, feature, state, &options, |o| {
156        accept_task(path, feature, task, reason, state, o)
157    })
158}
159
160fn accept_task(
161    path: &Path,
162    feature: Option<&str>,
163    task: &str,
164    reason: &str,
165    state: &Path,
166    options: &crate::RunOptions,
167) -> CommandResult {
168    let refuse = |status, m: String| CommandResult::new("accept", status, &m);
169    let wt = crate::pipeline::workspace(path, feature, state, options);
170    let Some(CommandData::Worktree(w)) = wt.data() else {
171        return CommandResult::carried("accept", &wt);
172    };
173    if wt.status() != CommandStatus::Ok {
174        return CommandResult::carried("accept", &wt);
175    }
176    let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
177        return refuse(CommandStatus::Error, "worktree without a path".to_owned());
178    };
179    let wt_root = PathBuf::from(&wt_path);
180    if options.session.is_none() && crate::worktree::require_clean(&wt_root).is_err() {
181        return refuse(
182            CommandStatus::GitPrecondition,
183            format!(
184                "the worktree {wt_path} has uncommitted changes; the next `speccoder run` sets them aside, or commit them yourself"
185            ),
186        )
187        .with_code("WORKTREE-DIRTY");
188    }
189    let project = PathBuf::from(&project_dir);
190    let format = crate::format::at(&project);
191    let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
192        return refuse(
193            CommandStatus::NotAProject,
194            format.messages().no_project_in_worktree.to_owned(),
195        );
196    };
197    let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
198        return refuse(
199            CommandStatus::NotFound,
200            format!("{} has no {}", w.feature, format.messages().tasks_file),
201        );
202    };
203    let Some(t) = tasks.tasks.iter().find(|t| t.id == task) else {
204        return refuse(
205            CommandStatus::NotFound,
206            format!("no task {task} in {}", w.feature),
207        );
208    };
209    if t.done {
210        return refuse(
211            CommandStatus::TaskNotReady,
212            format!("{task} is already done"),
213        )
214        .with_code("TASK-DONE");
215    }
216    let reason = one_line(reason);
217    let by = person(&project);
218    let message = format!(
219        "speccoder: {task} accepted by {by}\n\n{}\n\n{reason}\n\nSpec-Task: {}/{task}\nVerdict: attested\nAccepted-By: {by}\nReason: {reason}\n",
220        t.title, w.feature
221    );
222    let file = project.join(&tasks.path);
223    let committed = match &options.session {
224        None => format.tick(&file, task, &t.line_hash).and_then(|()| {
225            crate::commit_attempt(&wt_root, &message).map_err(|e| format!("commit failed: {e}"))
226        }),
227        // In place: the checkout and the intent as the session found them.
228        Some(session) => {
229            let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
230            let Ok(mut s) = session.lock() else {
231                return refuse(
232                    CommandStatus::Error,
233                    "the in-place session is unavailable".to_owned(),
234                );
235            };
236            if let Err(e) = s.check(&policy) {
237                return e.result("accept");
238            }
239            let digest =
240                crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
241            let done = s.commit_and_tick(
242                &plane_dir_of(state, &project),
243                &file,
244                &t.line_hash,
245                &message,
246                &digest,
247                || format.tick(&file, task, &t.line_hash),
248            );
249            match (done.commit, done.ticked) {
250                (Some(c), true) => Ok(c),
251                _ => Err(done.problems.join("; ")),
252            }
253        }
254    };
255    match committed {
256        Ok(commit) => {
257            let attested = attest_decision(
258                &wt_root,
259                &project,
260                t,
261                (
262                    &w.feature,
263                    &snap
264                        .feature
265                        .as_ref()
266                        .map(|f| f.feature.dir.clone())
267                        .unwrap_or_default(),
268                ),
269                (&commit, "attested"),
270                (&by, &reason),
271            );
272            done(
273                "accept",
274                ControlReport {
275                    feature: w.feature.clone(),
276                    task: task.to_owned(),
277                    action: "accepted".to_owned(),
278                    text: Some(reason),
279                    record: Some(commit),
280                },
281                &format!(
282                    "{task} ticked as attested by {by} (not verified); `report` shows it as such{attested}"
283                ),
284            )
285        }
286        Err(e) => refuse(CommandStatus::Error, e),
287    }
288}
289
290/// The state directory of the project at `project`.
291fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
292    crate::worktree::project_state_dir(state, project)
293}
294
295// ── skip ───────────────────────────────────────────────────────────────────
296
297/// Skipped tasks of `feature` with their reasons.
298#[must_use]
299pub fn skipped(root: &Path, feature: &str) -> BTreeMap<String, String> {
300    let format = format!("--format=%(refname){FS}%(trailers:key=Reason,valueonly){RS}");
301    let prefix = format!("{SKIPPED}/{feature}/");
302    let out = Git::at(root)
303        .run(&["for-each-ref", &format, &prefix])
304        .map(|b| String::from_utf8_lossy(&b).into_owned())
305        .unwrap_or_default();
306    out.split(RS)
307        .filter_map(|r| {
308            let (name, reason) = r.trim_start_matches('\n').split_once(FS)?;
309            Some((
310                name.strip_prefix(&prefix)?.to_owned(),
311                reason.trim().to_owned(),
312            ))
313        })
314        .collect()
315}
316
317/// `speccoder task skip T --reason R` (or `--undo`): the task is neither
318/// done nor attempted; the feature loop passes it by, and what depends on it
319/// stays blocked.
320pub fn skip(path: &Path, feature: Option<&str>, task: &str, reason: Option<&str>) -> CommandResult {
321    let (feature, root) = match locate("skip", path, feature) {
322        Ok(x) => x,
323        Err(r) => return *r,
324    };
325    let git = Git::at(&root);
326    let reference = format!("{SKIPPED}/{feature}/{task}");
327    let report = |action: &str, text: Option<String>| ControlReport {
328        feature: feature.clone(),
329        task: task.to_owned(),
330        action: action.to_owned(),
331        text,
332        record: Some(reference.clone()),
333    };
334    let Some(reason) = reason else {
335        let _ = git.run(&["update-ref", "-d", &reference]);
336        return done(
337            "skip",
338            report("unskipped", None),
339            &format!("{task} is no longer skipped"),
340        );
341    };
342    if !open_tasks(&root, &feature).iter().any(|t| t == task) {
343        return CommandResult::new(
344            "skip",
345            CommandStatus::NotFound,
346            &format!("{task} is not an open task of {feature}"),
347        );
348    }
349    let reason = one_line(reason);
350    let by = person(&root);
351    let made = git
352        .line(&["rev-parse", "HEAD^{tree}"])
353        .and_then(|tree| {
354            git.line(&[
355                "-c",
356                "user.name=speccoder",
357                "-c",
358                "user.email=speccoder@users.noreply.invalid",
359                "-c",
360                "commit.gpgsign=false",
361                "commit-tree",
362                &tree,
363                "-m",
364                &format!("speccoder: skip {task}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nSkipped-By: {by}\nReason: {reason}\n"),
365            ])
366        })
367        .and_then(|commit| git.run(&["update-ref", &reference, &commit]).map(|_| commit));
368    match made {
369        Ok(commit) => {
370            let snap = crate::format::at(&root).inspect(&root, Some(&feature));
371            let unit = snap.as_ref().and_then(|s| s.feature.as_ref());
372            let attested = unit
373                .and_then(|u| {
374                    u.tasks
375                        .as_ref()
376                        .and_then(|t| t.tasks.iter().find(|x| x.id == task))
377                        .map(|t| (u, t))
378                })
379                .map(|(u, t)| {
380                    attest_decision(
381                        &root,
382                        &root,
383                        t,
384                        (&feature, &u.feature.dir),
385                        (&commit, "skipped"),
386                        (&by, &reason),
387                    )
388                })
389                .unwrap_or_default();
390            done(
391                "skip",
392                report("skipped", Some(reason)),
393                &format!(
394                    "{task} skipped: runs pass it by, and tasks depending on it stay blocked (`skip --task {task} --undo` to take it back){attested}"
395                ),
396            )
397        }
398        Err(e) => CommandResult::new("skip", CommandStatus::GitPrecondition, &e.0),
399    }
400}
401
402// ── nudge ──────────────────────────────────────────────────────────────────
403
404/// `speccoder task rule <task> --tests-right --reason …`: a
405/// person rules that the tests of a held attempt are right — the flagged
406/// alternative is not what the spec allows. The ruling is bound to the
407/// alternative's digest; the next `run` of the task judges the held attempt
408/// again from the journal and ticks it with the ruling recorded.
409pub fn rule(
410    path: &Path,
411    feature: Option<&str>,
412    task: &str,
413    reason: &str,
414    variant: Option<&str>,
415    state: &Path,
416) -> CommandResult {
417    let (feature, root) = match locate("rule", path, feature) {
418        Ok(x) => x,
419        Err(r) => return *r,
420    };
421    if reason.trim().is_empty() {
422        return CommandResult::new("rule", CommandStatus::UsageError, "a ruling needs a reason");
423    }
424    let keep = crate::strength::dir(
425        &crate::worktree::project_state_dir(state, &root),
426        &feature,
427        task,
428    );
429    let Some(held) = crate::strength::held(&keep) else {
430        return CommandResult::new(
431            "rule",
432            CommandStatus::NotFound,
433            &format!("{task} has no attempt held for a ruling on its tests"),
434        )
435        .with_code("NOT-FOUND");
436    };
437    let chosen: Vec<_> = held
438        .findings
439        .iter()
440        .filter(|p| variant.is_none_or(|v| p.id == v))
441        .collect();
442    if chosen.is_empty() {
443        return CommandResult::new(
444            "rule",
445            CommandStatus::NotFound,
446            &format!(
447                "{task} has no finding `{}` (held: {})",
448                variant.unwrap_or_default(),
449                held.findings
450                    .iter()
451                    .map(|p| p.id.as_str())
452                    .collect::<Vec<_>>()
453                    .join(", ")
454            ),
455        )
456        .with_code("NOT-FOUND");
457    }
458    let by = crate::approvals::Who::local_in(&root, "cli").name;
459    for p in &chosen {
460        let ruling = speccoder_core::strength::Ruling {
461            digest: p.digest.clone(),
462            scenario: p.scenario.clone(),
463            by: by.clone(),
464            reason: reason.trim().to_owned(),
465        };
466        if let Err(e) = crate::strength::add_ruling(&keep, ruling) {
467            return CommandResult::new("rule", CommandStatus::Error, &e.to_string());
468        }
469    }
470    done(
471        "rule",
472        ControlReport {
473            feature,
474            task: task.to_owned(),
475            action: "ruled_tests_right".to_owned(),
476            text: Some(reason.trim().to_owned()),
477            record: None,
478        },
479        &format!(
480            "ruled by {by}: {task}'s tests are right about {}; `speccoder run --task {task}` judges the held attempt again and ticks it",
481            chosen
482                .iter()
483                .map(|p| format!("{} ({})", p.id, p.scenario))
484                .collect::<Vec<_>>()
485                .join(", ")
486        ),
487    )
488}
489
490/// `speccoder task nudge T "note"` (or `--clear`): a person's note for the
491/// task's next attempts, journaled with them.
492pub fn nudge_with(
493    path: &Path,
494    feature: Option<&str>,
495    task: &str,
496    note: Option<&str>,
497    state: &Path,
498) -> CommandResult {
499    let (feature, root) = match locate("nudge", path, feature) {
500        Ok(x) => x,
501        Err(r) => return *r,
502    };
503    let dir = crate::worktree::project_state_dir(state, &root);
504    let mut decisions = match crate::approvals::Decisions::load(&dir) {
505        Ok(d) => d,
506        Err(e) => return CommandResult::new("nudge", CommandStatus::ConfigError, &e),
507    };
508    let entry = decisions
509        .tasks
510        .entry(format!("{feature}/{task}"))
511        .or_default();
512    let (action, message) = match note {
513        Some(n) => {
514            entry.nudges.push(n.trim().to_owned());
515            (
516                "nudged",
517                format!("{task}'s next attempts will read: {}", n.trim()),
518            )
519        }
520        None => {
521            entry.nudges.clear();
522            ("nudges_cleared", format!("{task} has no notes any more"))
523        }
524    };
525    if let Err(e) = decisions.save(&dir) {
526        return CommandResult::new("nudge", CommandStatus::Error, &e);
527    }
528    done(
529        "nudge",
530        ControlReport {
531            feature,
532            task: task.to_owned(),
533            action: action.to_owned(),
534            text: note.map(|n| n.trim().to_owned()),
535            record: None,
536        },
537        &message,
538    )
539}
540
541// ── cost ───────────────────────────────────────────────────────────────────
542
543fn add(total: &mut Usage, u: &serde_json::Value) {
544    let n = |k: &str| u[k].as_u64().unwrap_or(0);
545    total.input_tokens += n("input_tokens");
546    total.output_tokens += n("output_tokens");
547    total.cache_read_tokens += n("cache_read_tokens");
548    total.cache_write_tokens += n("cache_write_tokens");
549    if let Some(c) = u["cost_usd"].as_f64() {
550        total.usd = Some(total.usd.unwrap_or(0.0) + c);
551    }
552}
553
554/// Prices `u` from `[model]`, unless its cost was reported (an external
555/// agent's own figure, which the configured prices do not describe).
556fn price(u: &mut Usage, prices: Option<crate::substrate::model_config::Prices>) {
557    if let Some(p) = prices.filter(|_| u.usd.is_none()) {
558        u.usd = Some(p.usd(
559            u.input_tokens,
560            u.output_tokens,
561            u.cache_read_tokens,
562            u.cache_write_tokens,
563        ));
564    }
565}
566
567/// `speccoder report --cost`: tokens per task — every attempt recorded in git, the
568/// passing one and the saved ones — plus convergence, priced when `[model]`
569/// states prices.
570pub fn cost(path: &Path, feature: Option<&str>) -> CommandResult {
571    let (feature, root) = match locate("cost", path, feature) {
572        Ok(x) => x,
573        Err(r) => return *r,
574    };
575    let model = crate::verify::find_config(&root)
576        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
577        .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten());
578    let prices = model.as_ref().and_then(|m| m.prices);
579
580    let branch = format!("speccoder/{feature}");
581    let format = format!(
582        "--format=%s{FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
583    );
584    let log = Git::at(&root)
585        .run(&["log", &format, &branch])
586        .map(|b| String::from_utf8_lossy(&b).into_owned())
587        .unwrap_or_default();
588    let mut per_task: BTreeMap<String, (usize, Usage)> = BTreeMap::new();
589    let mut convergence = Usage::default();
590    for record in log.split(RS) {
591        let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
592        let [subject, task, usage] = f[..] else {
593            continue;
594        };
595        let Some(u) = crate::attempts::parse_usage(usage.trim()) else {
596            continue;
597        };
598        if let Some(t) = task.trim().strip_prefix(&format!("{feature}/")) {
599            let e = per_task.entry(t.to_owned()).or_default();
600            e.0 += 1;
601            add(&mut e.1, &u);
602        } else if subject.starts_with("speccoder: converge round") {
603            add(&mut convergence, &u);
604        }
605    }
606    for saved in crate::attempts::list(&root, &feature) {
607        if let Some(u) = saved.usage.as_ref() {
608            let e = per_task.entry(saved.task.clone()).or_default();
609            e.0 += 1;
610            add(&mut e.1, u);
611        }
612    }
613
614    let mut total = Usage::default();
615    let mut tasks = Vec::new();
616    for (task, (attempts, mut usage)) in per_task {
617        total.input_tokens += usage.input_tokens;
618        total.output_tokens += usage.output_tokens;
619        total.cache_read_tokens += usage.cache_read_tokens;
620        total.cache_write_tokens += usage.cache_write_tokens;
621        price(&mut usage, prices);
622        tasks.push(TaskCost {
623            task,
624            attempts,
625            usage,
626        });
627    }
628    total.input_tokens += convergence.input_tokens;
629    total.output_tokens += convergence.output_tokens;
630    total.cache_read_tokens += convergence.cache_read_tokens;
631    total.cache_write_tokens += convergence.cache_write_tokens;
632    price(&mut convergence, prices);
633    let priced: Vec<f64> = tasks
634        .iter()
635        .map(|t| &t.usage)
636        .chain([&convergence])
637        .filter_map(|u| u.usd)
638        .collect();
639    total.usd = (!priced.is_empty()).then(|| priced.iter().sum());
640    let message = match total.usd {
641        Some(usd) => format!(
642            "{} tokens in, {} out ({} from the cache): ${usd:.4}",
643            total.input_tokens, total.output_tokens, total.cache_read_tokens
644        ),
645        None => format!(
646            "{} tokens in, {} out ({} from the cache); add price_input_per_mtok and price_output_per_mtok to [model] for money",
647            total.input_tokens, total.output_tokens, total.cache_read_tokens
648        ),
649    };
650    CommandResult::new("cost", CommandStatus::Ok, &message).with_data(CommandData::Cost(Box::new(
651        CostReport {
652            feature,
653            tasks,
654            convergence,
655            total,
656            priced_model: prices.and(model.map(|m| format!("{}/{}", m.provider, m.model))),
657        },
658    )))
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn cached_input_is_priced_at_the_cache_rate() {
667        let mut u = Usage {
668            input_tokens: 1_000_000,
669            output_tokens: 100_000,
670            cache_read_tokens: 600_000,
671            cache_write_tokens: 0,
672            usd: None,
673        };
674        price(
675            &mut u,
676            Some(crate::substrate::model_config::Prices {
677                input: 1.0,
678                output: 4.0,
679                cache_read: 0.1,
680                cache_write: 1.25,
681                cache_write_1h: 2.0,
682                one_hour: false,
683            }),
684        );
685        // 0.4M fresh × $1 + 0.6M cached × $0.1 + 0.1M out × $4
686        assert!(
687            (u.usd.unwrap_or_default() - 0.86).abs() < 1e-9,
688            "{:?}",
689            u.usd
690        );
691    }
692}