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
19pub use crate::attest::notes::SKIPPED;
20
21const FS: char = '\u{1f}';
22const RS: char = '\u{1e}';
23
24/// One line: trailers and subjects never span lines.
25pub(crate) fn one_line(text: &str) -> String {
26    text.split_whitespace().collect::<Vec<_>>().join(" ")
27}
28
29/// The person, named as every approval names them: `<name> (asserted)`.
30pub(crate) fn person(root: &Path) -> String {
31    format!("{} (asserted)", crate::approvals::who_in(root))
32}
33
34/// A person's decision on a task as an attestation: one
35/// `task-verification`, verdict `attested` or `skipped`, no summary. A
36/// line for the message; empty when `[attest]` is off.
37pub(crate) fn attest_decision(
38    repo: &Path,
39    project: &Path,
40    task: &speccoder_core::snapshot::Task,
41    unit: (&str, &str),
42    record: (&str, &str),
43    decider: (&str, &str),
44) -> String {
45    let config = crate::verify::find_config(project)
46        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
47        .unwrap_or_default();
48    let Ok(run_config) = crate::substrate::run_config::parse(&config) else {
49        return String::new();
50    };
51    if !run_config.attest.enabled {
52        return String::new();
53    }
54    let adapter = crate::format::at(project);
55    let format = format!("{:?}", adapter.kind()).to_lowercase();
56    let requirements = adapter.requirement_ids(project, unit.1);
57    let tick = crate::attest::Tick {
58        repo,
59        config: &config,
60        attest: &run_config.attest,
61        format: &format,
62        feature: unit.0,
63        feature_dir: unit.1,
64        task: &task.id,
65        title: &task.title,
66        line_hash: &task.line_hash,
67        section: task.story.as_deref(),
68        section_text: "",
69        requirements: &requirements,
70        commit: record.0,
71        verdict: record.1,
72        sandbox: serde_json::Value::Null,
73        executor: serde_json::Value::Null,
74        model: serde_json::Value::Null,
75        bug: None,
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 outcome a run's set-aside changes are saved with.
95pub const SET_ASIDE: &str = "set_aside";
96
97/// The files `commit` changes against its parent, repository-relative.
98#[must_use]
99pub fn changed_files(root: &Path, commit: &str) -> Vec<String> {
100    Git::at(root)
101        .run(&["diff", "--name-only", &format!("{commit}^"), commit])
102        .map(|o| {
103            String::from_utf8_lossy(&o)
104                .lines()
105                .map(str::to_owned)
106                .collect()
107        })
108        .unwrap_or_default()
109}
110
111/// Whether a saved attempt holds changes a run set aside (0.1.0 saved
112/// them as `interrupted`, with this reason).
113#[must_use]
114pub fn is_set_aside(a: &crate::attempts::Saved) -> bool {
115    a.outcome == SET_ASIDE
116        || (a.outcome == "interrupted"
117            && a.reason
118                .starts_with("the worktree had uncommitted changes when a run started"))
119}
120
121/// The newest changes set aside before `task`'s run.
122fn set_aside_of(root: &Path, feature: &str, task: &str) -> Option<crate::attempts::Saved> {
123    crate::attempts::list(root, feature)
124        .into_iter()
125        .find(|a| a.task == task && is_set_aside(a))
126}
127
128/// `speccoder task restore TASK`: the changes set aside before the task's
129/// run, committed onto `speccoder/<feature>` as the person's own — merged
130/// three ways when the branch moved since, refused when they conflict.
131#[must_use]
132pub fn restore(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
133    let (feature, root) = match locate("restore", path, feature) {
134        Ok(x) => x,
135        Err(r) => return *r,
136    };
137    let refuse = |status, m: String| CommandResult::new("restore", status, &m);
138    let Some(saved) = set_aside_of(&root, &feature, task) else {
139        return refuse(
140            CommandStatus::NotFound,
141            format!("nothing was set aside before {task}'s run"),
142        );
143    };
144    let git = Git::at(&root);
145    let branch = format!("refs/heads/speccoder/{feature}");
146    let (Ok(tip), Ok(parent)) = (
147        git.line(&["rev-parse", "--verify", &branch]),
148        git.line(&["rev-parse", &format!("{}^", saved.commit)]),
149    ) else {
150        return refuse(
151            CommandStatus::NotFound,
152            format!("speccoder/{feature} or the saved changes are gone"),
153        );
154    };
155    let tree = if tip == parent {
156        git.line(&["rev-parse", &format!("{}^{{tree}}", saved.commit)])
157    } else {
158        git.line(&[
159            "merge-tree",
160            "--write-tree",
161            &format!("--merge-base={parent}"),
162            &tip,
163            &saved.commit,
164        ])
165    };
166    let Ok(tree) = tree else {
167        return refuse(
168            CommandStatus::NeedsHuman,
169            format!(
170                "the set-aside changes no longer apply cleanly to speccoder/{feature}; nothing was changed — see them with `git show {}`",
171                saved.reference
172            ),
173        );
174    };
175    let files = changed_files(&root, &saved.commit);
176    let message = format!(
177        "keep the changes set aside before {task}\n\n{}\n\nKept-From: {}",
178        files.join("\n"),
179        saved.reference
180    );
181    let kept = match git.line(&[
182        "-c",
183        "commit.gpgsign=false",
184        "commit-tree",
185        tree.lines().next().unwrap_or_default(),
186        "-p",
187        &tip,
188        "-m",
189        &message,
190    ]) {
191        Ok(kept) => kept,
192        // git says why (no identity configured, most often).
193        Err(e) => {
194            return refuse(
195                CommandStatus::Error,
196                format!("the commit could not be made: {e}"),
197            );
198        }
199    };
200    // The worktree follows its branch: clean, it fast-forwards; with
201    // changes, nothing moves.
202    let moved = match crate::worktree::worktree_for(&git, &format!("speccoder/{feature}")) {
203        Ok(Some(wt)) => {
204            // Untracked build output does not stand in the way; edits to
205            // tracked files do, and are named.
206            let dirty: Vec<String> = Git::at(&wt)
207                .run(&["status", "--porcelain=v1", "--untracked-files=no"])
208                .map(|o| {
209                    String::from_utf8_lossy(&o)
210                        .lines()
211                        .map(|l| l.get(3..).unwrap_or(l).to_owned())
212                        .collect()
213                })
214                .unwrap_or_default();
215            if !dirty.is_empty() {
216                return refuse(
217                    CommandStatus::NeedsHuman,
218                    format!(
219                        "the worktree {} has uncommitted changes to {}; nothing was changed",
220                        wt.display(),
221                        dirty.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
222                            + if dirty.len() > 5 { ", …" } else { "" }
223                    ),
224                );
225            }
226            // An untracked copy identical to what is kept (a lockfile the
227            // checks wrote again) gives way; a different one stops the merge.
228            let wt_git = Git::at(&wt);
229            for f in &files {
230                let p = wt.join(f);
231                let same = p.is_file()
232                    && wt_git
233                        .line(&["ls-files", "--error-unmatch", "--", f])
234                        .is_err()
235                    && wt_git.line(&["hash-object", "--", f]).ok()
236                        == git.line(&["rev-parse", &format!("{kept}:{f}")]).ok();
237                if same {
238                    let _ = std::fs::remove_file(&p);
239                }
240            }
241            wt_git.run(&["merge", "--ff-only", "-q", &kept]).map(|_| ())
242        }
243        _ => git.run(&["update-ref", &branch, &kept, &tip]).map(|_| ()),
244    };
245    if let Err(e) = moved {
246        return refuse(
247            CommandStatus::Error,
248            format!("speccoder/{feature} could not move: {e}"),
249        );
250    }
251    let _ = git.run(&["update-ref", "-d", &saved.reference]);
252    done(
253        "restore",
254        ControlReport {
255            feature: feature.clone(),
256            task: task.to_owned(),
257            action: "restored".to_owned(),
258            text: Some(kept.chars().take(12).collect()),
259            record: Some(saved.reference.clone()),
260        },
261        &format!(
262            "kept the changes set aside before {task} ({} file(s)) as {} on speccoder/{feature}",
263            files.len(),
264            kept.chars().take(7).collect::<String>()
265        ),
266    )
267}
268
269/// `speccoder task drop TASK`: every change set aside before the task's
270/// runs, discarded.
271#[must_use]
272pub fn drop_set_aside(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
273    let (feature, root) = match locate("drop", path, feature) {
274        Ok(x) => x,
275        Err(r) => return *r,
276    };
277    let saved: Vec<crate::attempts::Saved> = crate::attempts::list(&root, &feature)
278        .into_iter()
279        .filter(|a| a.task == task && is_set_aside(a))
280        .collect();
281    if saved.is_empty() {
282        return CommandResult::new(
283            "drop",
284            CommandStatus::NotFound,
285            &format!("nothing was set aside before {task}'s run"),
286        );
287    }
288    let git = Git::at(&root);
289    for a in &saved {
290        if let Err(e) = git.run(&["update-ref", "-d", &a.reference]) {
291            return CommandResult::new("drop", CommandStatus::Error, &e.to_string());
292        }
293    }
294    let n = saved.len();
295    done(
296        "drop",
297        ControlReport {
298            feature,
299            task: task.to_owned(),
300            action: "dropped".to_owned(),
301            text: Some(n.to_string()),
302            record: saved.first().map(|a| a.reference.clone()),
303        },
304        &if n == 1 {
305            format!("discarded the changes set aside before {task}")
306        } else {
307            format!("discarded the {n} sets of changes set aside before {task}")
308        },
309    )
310}
311
312/// The unit of the bug `slug` in the format's own bug structure, and the
313/// project root: the bug must be there (its directory), and a `feature`
314/// given must name that unit.
315fn bug_unit_of(
316    path: &Path,
317    feature: Option<&str>,
318    slug: &str,
319) -> Result<(String, PathBuf), Box<CommandResult>> {
320    let not_found = |m: String| Box::new(CommandResult::new("skip", CommandStatus::NotFound, &m));
321    if !speccoder_core::bug::valid_slug(slug) {
322        return Err(Box::new(
323            CommandResult::new(
324                "skip",
325                CommandStatus::UsageError,
326                &format!("`{slug}` is not a bug slug"),
327            )
328            .with_code("USAGE"),
329        ));
330    }
331    let opened = crate::format::open(path, None)
332        .map_err(|r| Box::new(CommandResult::new("skip", r.status, &r.message)))?;
333    let root = PathBuf::from(&opened.snapshot.root);
334    let format = crate::format::at(&root);
335    let home = format
336        .bug_unit(&root)
337        .ok()
338        .filter(|h| h.kind.native())
339        .ok_or_else(|| {
340            not_found(format!(
341                "bug:{slug} names a bug of the format's own bug structure, which this project does not have"
342            ))
343        })?;
344    let (dir, unit) = format
345        .bug_dir(&home, slug)
346        .filter(|(dir, _)| root.join(dir).is_dir())
347        .ok_or_else(|| not_found(format!("no bug {slug} in {}", home.location)))?;
348    if let Some(f) = feature
349        && f != unit
350        && f != dir
351    {
352        return Err(not_found(format!(
353            "bug:{slug} is a task of {unit}, not of {f}"
354        )));
355    }
356    Ok((unit, root))
357}
358
359/// The selected feature and the project root of the user's checkout.
360fn locate(
361    command: &str,
362    path: &Path,
363    feature: Option<&str>,
364) -> Result<(String, PathBuf), Box<CommandResult>> {
365    let inspected = crate::inspect(path, feature);
366    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
367        return Err(Box::new(CommandResult::carried(command, &inspected)));
368    };
369    let Some(f) = snapshot.feature.as_ref() else {
370        return Err(Box::new(CommandResult::new(
371            command,
372            CommandStatus::SelectionNeeded,
373            inspected.message(),
374        )));
375    };
376    Ok((f.feature.name.clone(), PathBuf::from(&snapshot.root)))
377}
378
379/// The feature's open tasks as `report` sees them (branch first).
380fn open_tasks(root: &Path, feature: &str) -> Vec<String> {
381    match crate::status::report(root, Some(feature)).data() {
382        Some(CommandData::Report(r)) => r
383            .tasks
384            .iter()
385            .filter(|t| matches!(t.state.as_str(), "ready" | "blocked" | "skipped"))
386            .map(|t| t.id.clone())
387            .collect(),
388        _ => Vec::new(),
389    }
390}
391
392// ── accept ─────────────────────────────────────────────────────────────────
393
394/// `speccoder task accept T --reason R`: a person attests the task is
395/// done. speccoder ticks it and commits `Verdict: attested`.
396pub fn accept_with(
397    path: &Path,
398    feature: Option<&str>,
399    task: &str,
400    reason: &str,
401    state: &Path,
402) -> CommandResult {
403    accept_in(path, feature, task, reason, state, false)
404}
405
406/// [`accept_with`], in the person's checkout when `in_place` (`accept
407/// --in-place`): committed with the in-place trailers, then ticked.
408pub fn accept_in(
409    path: &Path,
410    feature: Option<&str>,
411    task: &str,
412    reason: &str,
413    state: &Path,
414    in_place: bool,
415) -> CommandResult {
416    let options = crate::RunOptions {
417        in_place,
418        ..crate::RunOptions::default()
419    };
420    accept_in_session(path, feature, task, reason, state, &options)
421}
422
423/// [`accept_in`] within `options`' in-place session, when a run holds one
424/// (an answer at its stop), or one of its own when it asks for in place.
425#[must_use]
426pub fn accept_in_session(
427    path: &Path,
428    feature: Option<&str>,
429    task: &str,
430    reason: &str,
431    state: &Path,
432    options: &crate::RunOptions,
433) -> CommandResult {
434    crate::pipeline::in_session("accept", path, feature, state, options, |o| {
435        accept_task(path, feature, task, reason, state, o)
436    })
437}
438
439fn accept_task(
440    path: &Path,
441    feature: Option<&str>,
442    task: &str,
443    reason: &str,
444    state: &Path,
445    options: &crate::RunOptions,
446) -> CommandResult {
447    let refuse = |status, m: String| CommandResult::new("accept", status, &m);
448    let wt = crate::pipeline::workspace(path, feature, state, options);
449    let Some(CommandData::Worktree(w)) = wt.data() else {
450        return CommandResult::carried("accept", &wt);
451    };
452    if wt.status() != CommandStatus::Ok {
453        return CommandResult::carried("accept", &wt);
454    }
455    let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
456        return refuse(CommandStatus::Error, "worktree without a path".to_owned());
457    };
458    let wt_root = PathBuf::from(&wt_path);
459    if options.session.is_none() && crate::worktree::require_clean(&wt_root).is_err() {
460        return refuse(
461            CommandStatus::GitPrecondition,
462            format!(
463                "the worktree {wt_path} has uncommitted changes; the next `speccoder run` sets them aside, or commit them yourself"
464            ),
465        )
466        .with_code("WORKTREE-DIRTY");
467    }
468    let project = PathBuf::from(&project_dir);
469    let format = crate::format::at(&project);
470    let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
471        return refuse(
472            CommandStatus::NotAProject,
473            format.messages().no_project_in_worktree.to_owned(),
474        );
475    };
476    let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
477        return refuse(
478            CommandStatus::NotFound,
479            format!("{} has no {}", w.feature, format.messages().tasks_file),
480        );
481    };
482    let Some(t) = tasks.tasks.iter().find(|t| t.id == task) else {
483        return refuse(
484            CommandStatus::NotFound,
485            format!("no task {task} in {}", w.feature),
486        );
487    };
488    if t.done {
489        return refuse(
490            CommandStatus::TaskNotReady,
491            format!("{task} is already done"),
492        )
493        .with_code("TASK-DONE");
494    }
495    // The attempt that stopped because speccoder could not tell whether
496    // its tests ran was saved and undone: accepting it accepts that code,
497    // so it is put back first — never a tick on the code without it.
498    let mut restored = String::new();
499    if let Some(saved) = crate::attempts::list(&wt_root, &w.feature)
500        .into_iter()
501        .find(|a| a.task == task && !is_set_aside(a))
502        .filter(|a| a.outcome == "collection_unknown")
503    {
504        if options.session.is_some() {
505            return refuse(
506                CommandStatus::GitPrecondition,
507                format!(
508                    "{task}'s attempt ({}) was saved and undone; in place it cannot be put back for you: restore it with `git checkout {} -- .`, then accept",
509                    saved.reference, saved.reference
510                ),
511            );
512        }
513        let tree = crate::git::Git::at(&wt_root)
514            .line(&["rev-parse", &format!("{}^{{tree}}", saved.commit)])
515            .map(|t| t.trim().to_owned());
516        let put_back = tree.map_err(|e| e.0).and_then(|tree| {
517            let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
518            crate::worktree::restore(&wt_root, tmp.path(), &crate::worktree::Checkpoint(tree))
519                .map_err(|e| e.0)
520        });
521        if let Err(e) = put_back {
522            return refuse(
523                CommandStatus::Error,
524                format!(
525                    "{task}'s saved attempt ({}) could not be put back: {e}",
526                    saved.reference
527                ),
528            );
529        }
530        restored = format!(" with its saved attempt ({})", saved.reference);
531    }
532    // A person's word does not pass the person's own gates (spec 095).
533    let gates = match crate::sandbox::runner_at(&wt_root) {
534        Ok((runner, _)) => crate::hooks::gate::run(
535            &project,
536            &runner,
537            state,
538            crate::hooks::gate::Facts {
539                point: speccoder_core::hooks::Point::Tick,
540                feature: &w.feature,
541                task: Some(task),
542                branch: None,
543            },
544        ),
545        Err(e) => return refuse(CommandStatus::ConfigError, e),
546    };
547    if let Some(g) = speccoder_core::hooks::first_block(&gates) {
548        let code = g.code.clone().unwrap_or_else(|| "HOOK-BLOCKED".to_owned());
549        return refuse(
550            CommandStatus::NeedsHuman,
551            format!("{}; {task} is not ticked", g.blocked_line()),
552        )
553        .with_code(&code);
554    }
555    let gate_lines: String = gates
556        .iter()
557        .map(|g| format!("Gate: {} {}\n", g.name, g.outcome.as_str()))
558        .collect();
559    let reason = one_line(reason);
560    let by = person(&project);
561    let message = format!(
562        "speccoder: {task} accepted by {by}\n\n{}\n\n{reason}\n\nSpec-Task: {}/{task}\nVerdict: attested\nAccepted-By: {by}\nReason: {reason}\n{gate_lines}",
563        t.title, w.feature
564    );
565    let file = project.join(&tasks.path);
566    let committed = match &options.session {
567        None => format.tick(&file, task, &t.line_hash).and_then(|()| {
568            crate::commit_attempt(&wt_root, &message).map_err(|e| format!("commit failed: {e}"))
569        }),
570        // In place: the checkout and the intent as the session found them.
571        Some(session) => {
572            let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
573            let Ok(mut s) = session.lock() else {
574                return refuse(
575                    CommandStatus::Error,
576                    "the in-place session is unavailable".to_owned(),
577                );
578            };
579            if let Err(e) = s.check(&policy) {
580                return e.result("accept");
581            }
582            let digest =
583                crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
584            let done = s.commit_and_tick(
585                &plane_dir_of(state, &project),
586                &file,
587                &t.line_hash,
588                &message,
589                &digest,
590                || format.tick(&file, task, &t.line_hash),
591            );
592            match (done.commit, done.ticked) {
593                (Some(c), true) => Ok(c),
594                _ => Err(done.problems.join("; ")),
595            }
596        }
597    };
598    match committed {
599        Ok(commit) => {
600            let attested = attest_decision(
601                &wt_root,
602                &project,
603                t,
604                (
605                    &w.feature,
606                    &snap
607                        .feature
608                        .as_ref()
609                        .map(|f| f.feature.dir.clone())
610                        .unwrap_or_default(),
611                ),
612                (&commit, "attested"),
613                (&by, &reason),
614            );
615            done(
616                "accept",
617                ControlReport {
618                    feature: w.feature.clone(),
619                    task: task.to_owned(),
620                    action: "accepted".to_owned(),
621                    text: Some(reason),
622                    record: Some(commit),
623                },
624                &format!(
625                    "{task} ticked as attested by {by}{restored} (not verified); `report` shows it as such{attested}"
626                ),
627            )
628        }
629        Err(e) => refuse(CommandStatus::Error, e),
630    }
631}
632
633/// The state directory of the project at `project`.
634fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
635    crate::worktree::project_state_dir(state, project)
636}
637
638// ── skip ───────────────────────────────────────────────────────────────────
639
640/// A task id as a ref name component: `bug:<slug>` (a trailer-only task
641/// of the format's own bug structure) has a `:`, which git refuses in a ref.
642fn ref_task(task: &str) -> String {
643    task.replace('%', "%25").replace(':', "%3A")
644}
645
646/// [`ref_task`] read back.
647fn task_of_ref(name: &str) -> String {
648    name.replace("%3A", ":").replace("%25", "%")
649}
650
651/// Skipped tasks of `feature` with their reasons.
652#[must_use]
653pub fn skipped(root: &Path, feature: &str) -> BTreeMap<String, String> {
654    let format = format!("--format=%(refname){FS}%(trailers:key=Reason,valueonly){RS}");
655    let prefix = format!("{SKIPPED}/{feature}/");
656    let out = Git::at(root)
657        .run(&["for-each-ref", &format, &prefix])
658        .map(|b| String::from_utf8_lossy(&b).into_owned())
659        .unwrap_or_default();
660    out.split(RS)
661        .filter_map(|r| {
662            let (name, reason) = r.trim_start_matches('\n').split_once(FS)?;
663            Some((
664                task_of_ref(name.strip_prefix(&prefix)?),
665                reason.trim().to_owned(),
666            ))
667        })
668        .collect()
669}
670
671/// `speccoder task skip T --reason R` (or `--undo`): the task is neither
672/// done nor attempted; the feature loop passes it by, and what depends on it
673/// stays blocked.
674pub fn skip(path: &Path, feature: Option<&str>, task: &str, reason: Option<&str>) -> CommandResult {
675    // A bug of the format's own bug structure (`bug:<slug>`): no feature
676    // lists it as a task — its unit is found from its slug.
677    let located = match task.strip_prefix("bug:") {
678        Some(slug) => bug_unit_of(path, feature, slug),
679        None => locate("skip", path, feature),
680    };
681    let (feature, root) = match located {
682        Ok(x) => x,
683        Err(r) => return *r,
684    };
685    let git = Git::at(&root);
686    let reference = format!("{SKIPPED}/{feature}/{}", ref_task(task));
687    let report = |action: &str, text: Option<String>| ControlReport {
688        feature: feature.clone(),
689        task: task.to_owned(),
690        action: action.to_owned(),
691        text,
692        record: Some(reference.clone()),
693    };
694    let Some(reason) = reason else {
695        let _ = git.run(&["update-ref", "-d", &reference]);
696        return done(
697            "skip",
698            report("unskipped", None),
699            &format!("{task} is no longer skipped"),
700        );
701    };
702    if !task.starts_with("bug:") && !open_tasks(&root, &feature).iter().any(|t| t == task) {
703        return CommandResult::new(
704            "skip",
705            CommandStatus::NotFound,
706            &format!("{task} is not an open task of {feature}"),
707        );
708    }
709    if task.starts_with("bug:")
710        && let Some(state) = crate::worktree::state_dir()
711        && let Some(b) = crate::bug::state::load(
712            &crate::worktree::project_state_dir(&state, &root),
713            &feature,
714            task,
715        )
716        && b.commit.is_some()
717    {
718        return CommandResult::new(
719            "skip",
720            CommandStatus::TaskNotReady,
721            &format!("{task} is done already: its commit is recorded"),
722        )
723        .with_code("TASK-DONE");
724    }
725    let reason = one_line(reason);
726    let by = person(&root);
727    let made = git
728        .line(&["rev-parse", "HEAD^{tree}"])
729        .and_then(|tree| {
730            git.line(&[
731                "-c",
732                "user.name=speccoder",
733                "-c",
734                "user.email=speccoder@users.noreply.invalid",
735                "-c",
736                "commit.gpgsign=false",
737                "commit-tree",
738                &tree,
739                "-m",
740                &format!("speccoder: skip {task}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nSkipped-By: {by}\nReason: {reason}\n"),
741            ])
742        })
743        .and_then(|commit| git.run(&["update-ref", &reference, &commit]).map(|_| commit));
744    match made {
745        Ok(commit) => {
746            let snap = crate::format::at(&root).inspect(&root, Some(&feature));
747            let unit = snap.as_ref().and_then(|s| s.feature.as_ref());
748            let attested = unit
749                .and_then(|u| {
750                    u.tasks
751                        .as_ref()
752                        .and_then(|t| t.tasks.iter().find(|x| x.id == task))
753                        .map(|t| (u, t))
754                })
755                .map(|(u, t)| {
756                    attest_decision(
757                        &root,
758                        &root,
759                        t,
760                        (&feature, &u.feature.dir),
761                        (&commit, "skipped"),
762                        (&by, &reason),
763                    )
764                })
765                .unwrap_or_default();
766            done(
767                "skip",
768                report("skipped", Some(reason)),
769                &format!(
770                    "{task} skipped: runs pass it by, and tasks depending on it stay blocked (`skip --task {task} --undo` to take it back){attested}"
771                ),
772            )
773        }
774        Err(e) => CommandResult::new("skip", CommandStatus::GitPrecondition, &e.0),
775    }
776}
777
778// ── nudge ──────────────────────────────────────────────────────────────────
779
780/// `speccoder task rule <task> --tests-right --reason …`: a
781/// person rules that the tests of a held attempt are right — the flagged
782/// alternative is not what the spec allows. The ruling is bound to the
783/// alternative's digest; the next `run` of the task judges the held attempt
784/// again from the journal and ticks it with the ruling recorded.
785pub fn rule(
786    path: &Path,
787    feature: Option<&str>,
788    task: &str,
789    reason: &str,
790    variant: Option<&str>,
791    state: &Path,
792) -> CommandResult {
793    let (feature, root) = match locate("rule", path, feature) {
794        Ok(x) => x,
795        Err(r) => return *r,
796    };
797    if reason.trim().is_empty() {
798        return CommandResult::new("rule", CommandStatus::UsageError, "a ruling needs a reason");
799    }
800    let keep = crate::strength::dir(
801        &crate::worktree::project_state_dir(state, &root),
802        &feature,
803        task,
804    );
805    let Some(held) = crate::strength::held(&keep) else {
806        return CommandResult::new(
807            "rule",
808            CommandStatus::NotFound,
809            &format!("{task} has no attempt held for a ruling on its tests"),
810        )
811        .with_code("NOT-FOUND");
812    };
813    let chosen: Vec<_> = held
814        .findings
815        .iter()
816        .filter(|p| variant.is_none_or(|v| p.id == v))
817        .collect();
818    if chosen.is_empty() {
819        return CommandResult::new(
820            "rule",
821            CommandStatus::NotFound,
822            &format!(
823                "{task} has no finding `{}` (held: {})",
824                variant.unwrap_or_default(),
825                held.findings
826                    .iter()
827                    .map(|p| p.id.as_str())
828                    .collect::<Vec<_>>()
829                    .join(", ")
830            ),
831        )
832        .with_code("NOT-FOUND");
833    }
834    let by = crate::approvals::who_in(&root);
835    for p in &chosen {
836        let ruling = speccoder_core::strength::Ruling {
837            digest: p.digest.clone(),
838            scenario: p.scenario.clone(),
839            by: by.clone(),
840            reason: reason.trim().to_owned(),
841        };
842        if let Err(e) = crate::strength::add_ruling(&keep, ruling) {
843            return CommandResult::new("rule", CommandStatus::Error, &e.to_string());
844        }
845    }
846    done(
847        "rule",
848        ControlReport {
849            feature,
850            task: task.to_owned(),
851            action: "ruled_tests_right".to_owned(),
852            text: Some(reason.trim().to_owned()),
853            record: None,
854        },
855        &format!(
856            "ruled by {by}: {task}'s tests are right about {}; `speccoder run --task {task}` judges the held attempt again and ticks it",
857            chosen
858                .iter()
859                .map(|p| format!("{} ({})", p.id, p.scenario))
860                .collect::<Vec<_>>()
861                .join(", ")
862        ),
863    )
864}
865
866/// `speccoder task nudge T "note"` (or `--clear`): a person's note for the
867/// task's next attempts, journaled with them.
868pub fn nudge_with(
869    path: &Path,
870    feature: Option<&str>,
871    task: &str,
872    note: Option<&str>,
873    state: &Path,
874) -> CommandResult {
875    let (feature, root) = match locate("nudge", path, feature) {
876        Ok(x) => x,
877        Err(r) => return *r,
878    };
879    let dir = crate::worktree::project_state_dir(state, &root);
880    let mut decisions = match crate::approvals::Decisions::load(&dir) {
881        Ok(d) => d,
882        Err(e) => return CommandResult::new("nudge", CommandStatus::ConfigError, &e),
883    };
884    let entry = decisions
885        .tasks
886        .entry(format!("{feature}/{task}"))
887        .or_default();
888    let (action, message) = match note {
889        Some(n) => {
890            entry.nudges.push(n.trim().to_owned());
891            (
892                "nudged",
893                format!("{task}'s next attempts will read: {}", n.trim()),
894            )
895        }
896        None => {
897            entry.nudges.clear();
898            ("nudges_cleared", format!("{task} has no notes any more"))
899        }
900    };
901    if let Err(e) = decisions.save(&dir) {
902        return CommandResult::new("nudge", CommandStatus::Error, &e);
903    }
904    done(
905        "nudge",
906        ControlReport {
907            feature,
908            task: task.to_owned(),
909            action: action.to_owned(),
910            text: note.map(|n| n.trim().to_owned()),
911            record: None,
912        },
913        &message,
914    )
915}
916
917// ── cost ───────────────────────────────────────────────────────────────────
918
919fn add(total: &mut Usage, u: &serde_json::Value) {
920    let n = |k: &str| u[k].as_u64().unwrap_or(0);
921    total.input_tokens += n("input_tokens");
922    total.output_tokens += n("output_tokens");
923    total.cache_read_tokens += n("cache_read_tokens");
924    total.cache_write_tokens += n("cache_write_tokens");
925    if let Some(c) = u["cost_usd"].as_f64() {
926        total.usd = Some(total.usd.unwrap_or(0.0) + c);
927    }
928}
929
930/// Prices `u` from `[model]`, unless its cost was reported (an external
931/// agent's own figure, which the configured prices do not describe).
932fn price(u: &mut Usage, prices: Option<crate::substrate::model_config::Prices>) {
933    if let Some(p) = prices.filter(|_| u.usd.is_none()) {
934        u.usd = Some(p.usd(
935            u.input_tokens,
936            u.output_tokens,
937            u.cache_read_tokens,
938            u.cache_write_tokens,
939        ));
940    }
941}
942
943/// `speccoder report --cost`: tokens per task — every attempt recorded in git, the
944/// passing one and the saved ones — plus convergence, priced when `[model]`
945/// states prices.
946pub fn cost(path: &Path, feature: Option<&str>) -> CommandResult {
947    let (feature, root) = match locate("cost", path, feature) {
948        Ok(x) => x,
949        Err(r) => return *r,
950    };
951    let model = crate::verify::find_config(&root)
952        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
953        .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten());
954    let prices = model.as_ref().and_then(|m| m.prices);
955
956    let branch = format!("speccoder/{feature}");
957    let format = format!(
958        "--format=%s{FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
959    );
960    let log = Git::at(&root)
961        .run(&["log", &format, &branch])
962        .map(|b| String::from_utf8_lossy(&b).into_owned())
963        .unwrap_or_default();
964    let mut per_task: BTreeMap<String, (usize, Usage)> = BTreeMap::new();
965    let mut convergence = Usage::default();
966    for record in log.split(RS) {
967        let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
968        let [subject, task, usage] = f[..] else {
969            continue;
970        };
971        let Some(u) = crate::attempts::parse_usage(usage.trim()) else {
972            continue;
973        };
974        if let Some(t) = task.trim().strip_prefix(&format!("{feature}/")) {
975            let e = per_task.entry(t.to_owned()).or_default();
976            e.0 += 1;
977            add(&mut e.1, &u);
978        } else if subject.starts_with("speccoder: converge round") {
979            add(&mut convergence, &u);
980        }
981    }
982    let mut not_kept = Usage::default();
983    for saved in crate::attempts::list(&root, &feature) {
984        if let Some(u) = saved.usage.as_ref() {
985            let e = per_task.entry(saved.task.clone()).or_default();
986            e.0 += 1;
987            add(&mut e.1, u);
988            add(&mut not_kept, u);
989        }
990    }
991    price(&mut not_kept, prices);
992
993    let mut total = Usage::default();
994    let mut tasks = Vec::new();
995    for (task, (attempts, mut usage)) in per_task {
996        total.input_tokens += usage.input_tokens;
997        total.output_tokens += usage.output_tokens;
998        total.cache_read_tokens += usage.cache_read_tokens;
999        total.cache_write_tokens += usage.cache_write_tokens;
1000        price(&mut usage, prices);
1001        tasks.push(TaskCost {
1002            task,
1003            attempts,
1004            usage,
1005        });
1006    }
1007    total.input_tokens += convergence.input_tokens;
1008    total.output_tokens += convergence.output_tokens;
1009    total.cache_read_tokens += convergence.cache_read_tokens;
1010    total.cache_write_tokens += convergence.cache_write_tokens;
1011    price(&mut convergence, prices);
1012    let priced: Vec<f64> = tasks
1013        .iter()
1014        .map(|t| &t.usage)
1015        .chain([&convergence])
1016        .filter_map(|u| u.usd)
1017        .collect();
1018    total.usd = (!priced.is_empty()).then(|| priced.iter().sum());
1019    let asked = crate::ask::spent(&root, None, &feature);
1020    let mut ask = Usage {
1021        input_tokens: asked.input,
1022        output_tokens: asked.output,
1023        cache_read_tokens: asked.cache_read,
1024        ..Usage::default()
1025    };
1026    price(&mut ask, prices);
1027    // What attempts not kept cost, when any were saved.
1028    let wasted = match (not_kept.usd, total.usd) {
1029        _ if not_kept.input_tokens + not_kept.output_tokens == 0 => String::new(),
1030        (Some(n), Some(t)) if t > 0.0 => {
1031            format!("; ${n:.4} ({:.0}%) on attempts not kept", n / t * 100.0)
1032        }
1033        _ => format!(
1034            "; {} tokens on attempts not kept",
1035            not_kept.input_tokens + not_kept.output_tokens
1036        ),
1037    };
1038    let cap = crate::verify::find_config(&root)
1039        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1040        .and_then(|t| crate::substrate::run_config::parse(&t).ok())
1041        .and_then(|c| c.cap);
1042    let skipped = skipped(&root, &feature);
1043    let open = open_tasks(&root, &feature)
1044        .iter()
1045        .filter(|t| !skipped.contains_key(*t))
1046        .count();
1047    let estimate = estimate(&root, open, prices);
1048    let message = match total.usd {
1049        Some(usd) => format!(
1050            "{} tokens in, {} out ({} from the cache): ${usd:.4}{wasted}",
1051            total.input_tokens, total.output_tokens, total.cache_read_tokens
1052        ),
1053        None => format!(
1054            "{} tokens in, {} out ({} from the cache){wasted}; add price_input_per_mtok and price_output_per_mtok to [model] for money",
1055            total.input_tokens, total.output_tokens, total.cache_read_tokens
1056        ),
1057    };
1058    CommandResult::new("cost", CommandStatus::Ok, &message).with_data(CommandData::Cost(Box::new(
1059        CostReport {
1060            feature,
1061            tasks,
1062            convergence,
1063            total,
1064            priced_model: prices.and(model.map(|m| format!("{}/{}", m.provider, m.model))),
1065            ask,
1066            not_kept,
1067            estimate,
1068            cap: cap.map(|c| c.to_string()),
1069        },
1070    )))
1071}
1072
1073/// What `tasks` will likely cost: the average of the project's ticked
1074/// tasks (each tick's `Usage` trailer, its retries inside it) on every
1075/// branch, times `tasks`. `None` with no tick on record or no task to run.
1076#[must_use]
1077pub fn estimate(
1078    root: &Path,
1079    tasks: usize,
1080    prices: Option<crate::substrate::model_config::Prices>,
1081) -> Option<speccoder_core::Estimate> {
1082    if tasks == 0 {
1083        return None;
1084    }
1085    let format = format!(
1086        "--format=%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
1087    );
1088    let log = Git::at(root)
1089        .run(&["log", "--branches", &format])
1090        .map(|b| String::from_utf8_lossy(&b).into_owned())
1091        .unwrap_or_default();
1092    let mut seen = std::collections::BTreeSet::new();
1093    let mut sum = Usage::default();
1094    for record in log.split(RS) {
1095        let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
1096        let [task, usage] = f[..] else { continue };
1097        let (task, usage) = (task.trim(), usage.trim());
1098        if task.is_empty() || !seen.insert(task.to_owned()) {
1099            continue;
1100        }
1101        if let Some(u) = crate::attempts::parse_usage(usage) {
1102            add(&mut sum, &u);
1103        } else {
1104            seen.remove(task);
1105        }
1106    }
1107    let ticks = seen.len() as u64;
1108    if ticks == 0 {
1109        return None;
1110    }
1111    let times = |u: &Usage, n: u64, d: u64| Usage {
1112        input_tokens: u.input_tokens * n / d,
1113        output_tokens: u.output_tokens * n / d,
1114        cache_read_tokens: u.cache_read_tokens * n / d,
1115        cache_write_tokens: u.cache_write_tokens * n / d,
1116        usd: None,
1117    };
1118    let mut per_task = times(&sum, 1, ticks);
1119    let mut total = times(&sum, tasks as u64, ticks);
1120    price(&mut per_task, prices);
1121    price(&mut total, prices);
1122    Some(speccoder_core::Estimate {
1123        tasks,
1124        per_task,
1125        ticks: seen.len(),
1126        total,
1127    })
1128}
1129
1130/// A run's first words about money: the estimate for its `open` tasks and
1131/// its cap — `estimate: ~1.2M tokens ($3.40) for 4 tasks — 300k a task here · cap 2M`.
1132/// `None` when there is neither.
1133#[must_use]
1134pub fn spend_line(
1135    path: &Path,
1136    open: usize,
1137    cap: Option<crate::substrate::run_config::Cap>,
1138) -> Option<String> {
1139    let root = crate::verify::find_config(path).unwrap_or_else(|| path.to_path_buf());
1140    let prices = crate::substrate::model_config::parse(
1141        &std::fs::read_to_string(root.join(crate::verify::config::FILE)).unwrap_or_default(),
1142    )
1143    .ok()
1144    .flatten()
1145    .and_then(|m| m.prices);
1146    let short = crate::substrate::run_config::short_tokens;
1147    let tokens = |u: &Usage| u.input_tokens + u.output_tokens;
1148    let estimate = estimate(&root, open, prices).map(|e| {
1149        format!(
1150            "estimate: ~{} tokens{} for {} task{} — {} a task here, from {} ticked",
1151            short(tokens(&e.total)),
1152            e.total
1153                .usd
1154                .map(|d| format!(" (${d:.2})"))
1155                .unwrap_or_default(),
1156            e.tasks,
1157            if e.tasks == 1 { "" } else { "s" },
1158            short(tokens(&e.per_task)),
1159            e.ticks
1160        )
1161    });
1162    match (estimate, cap) {
1163        (None, None) => None,
1164        (Some(e), None) => Some(e),
1165        (None, Some(c)) => Some(format!("cap: {c}")),
1166        (Some(e), Some(c)) => Some(format!("{e} · cap {c}")),
1167    }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173
1174    #[test]
1175    fn cached_input_is_priced_at_the_cache_rate() {
1176        let mut u = Usage {
1177            input_tokens: 1_000_000,
1178            output_tokens: 100_000,
1179            cache_read_tokens: 600_000,
1180            cache_write_tokens: 0,
1181            usd: None,
1182        };
1183        price(
1184            &mut u,
1185            Some(crate::substrate::model_config::Prices {
1186                input: 1.0,
1187                output: 4.0,
1188                cache_read: 0.1,
1189                cache_write: 1.25,
1190                cache_write_1h: 2.0,
1191                one_hour: false,
1192            }),
1193        );
1194        // 0.4M fresh × $1 + 0.6M cached × $0.1 + 0.1M out × $4
1195        assert!(
1196            (u.usd.unwrap_or_default() - 0.86).abs() < 1e-9,
1197            "{:?}",
1198            u.usd
1199        );
1200    }
1201}
1202
1203/// The record of hosted skills' runs (one JSON line each): tokens and,
1204/// when the model is priced, money.
1205const SKILL_USAGE: &str = "skills.jsonl";
1206
1207/// Appends one hosted skill run's usage to the project's record.
1208pub(crate) fn record_skill_usage(
1209    plane: &Path,
1210    skill: &str,
1211    [input, output, cache_read, cache_write]: [u64; 4],
1212    prices: Option<crate::substrate::model_config::Prices>,
1213    calls: u64,
1214) {
1215    use std::io::Write as _;
1216    if input + output == 0 {
1217        return;
1218    }
1219    let usd = crate::progress::usd(prices, input, output, cache_read, cache_write);
1220    let line = serde_json::json!({
1221        "skill": skill,
1222        "input_tokens": input,
1223        "output_tokens": output,
1224        "usd": usd,
1225        "calls": calls,
1226    });
1227    if let Ok(mut f) = std::fs::OpenOptions::new()
1228        .create(true)
1229        .append(true)
1230        .open(plane.join(SKILL_USAGE))
1231    {
1232        let _ = writeln!(f, "{line}");
1233    }
1234}
1235
1236/// What running `skills` once each cost before, from the project's record
1237/// of hosted skill runs: per skill the median of its runs, summed. `None`
1238/// when a skill has never run here — no estimate is made up.
1239#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1240pub struct SkillCost {
1241    /// Tokens (input and output), the medians summed.
1242    pub tokens: u64,
1243    /// Model calls, the medians summed (`None`: a run on record did not count them).
1244    pub calls: Option<u64>,
1245    /// USD, when every run was priced.
1246    pub usd: Option<f64>,
1247    /// The fewest runs any of the skills has on record.
1248    pub runs: usize,
1249}
1250
1251/// [`SkillCost`] of `skills` in the project at `root`, and the model.
1252#[must_use]
1253pub fn skill_cost(root: &Path, skills: &[String]) -> (Option<SkillCost>, Option<String>) {
1254    let model = crate::verify::find_config(root)
1255        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1256        .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten())
1257        .map(|m| m.model);
1258    let Some(state) = crate::worktree::state_dir() else {
1259        return (None, model);
1260    };
1261    let plane = crate::worktree::project_state_dir(&state, root);
1262    let text = std::fs::read_to_string(plane.join(SKILL_USAGE)).unwrap_or_default();
1263    (cost_from(&text, skills), model)
1264}
1265
1266/// [`SkillCost`] of `skills` from the record's lines.
1267fn cost_from(text: &str, skills: &[String]) -> Option<SkillCost> {
1268    let rows: Vec<serde_json::Value> = text
1269        .lines()
1270        .filter_map(|l| serde_json::from_str(l).ok())
1271        .collect();
1272    let median = |mut v: Vec<f64>| -> f64 {
1273        v.sort_by(f64::total_cmp);
1274        let n = v.len();
1275        if n % 2 == 1 {
1276            v[n / 2]
1277        } else {
1278            (v[n / 2 - 1] + v[n / 2]) / 2.0
1279        }
1280    };
1281    let mut tokens = 0.0;
1282    let mut calls = Some(0.0);
1283    let mut usd = Some(0.0);
1284    let mut runs = usize::MAX;
1285    for skill in skills {
1286        let mine: Vec<&serde_json::Value> = rows
1287            .iter()
1288            .filter(|r| r["skill"] == skill.as_str())
1289            .collect();
1290        if mine.is_empty() {
1291            return None;
1292        }
1293        runs = runs.min(mine.len());
1294        #[allow(clippy::cast_precision_loss)]
1295        let t: Vec<f64> = mine
1296            .iter()
1297            .map(|r| {
1298                (r["input_tokens"].as_u64().unwrap_or(0) + r["output_tokens"].as_u64().unwrap_or(0))
1299                    as f64
1300            })
1301            .collect();
1302        tokens += median(t);
1303        #[allow(clippy::cast_precision_loss)]
1304        let counted: Vec<f64> = mine
1305            .iter()
1306            .filter_map(|r| r["calls"].as_u64())
1307            .map(|c| c as f64)
1308            .collect();
1309        calls = match calls {
1310            Some(sum) if counted.len() == mine.len() => Some(sum + median(counted)),
1311            _ => None,
1312        };
1313        let priced: Vec<f64> = mine.iter().filter_map(|r| r["usd"].as_f64()).collect();
1314        usd = match usd {
1315            Some(sum) if priced.len() == mine.len() => Some(sum + median(priced)),
1316            _ => None,
1317        };
1318    }
1319    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1320    let cost = SkillCost {
1321        tokens: tokens.round() as u64,
1322        calls: calls.map(|c| c.round() as u64),
1323        usd,
1324        runs,
1325    };
1326    Some(cost)
1327}
1328
1329#[cfg(test)]
1330mod skill_cost_tests {
1331    use super::*;
1332
1333    #[test]
1334    fn the_cost_line_is_the_median_of_runs_on_record_or_nothing() {
1335        let rows = [
1336            r#"{"skill":"speckit-specify","input_tokens":100,"output_tokens":10,"usd":0.01,"calls":3}"#,
1337            r#"{"skill":"speckit-specify","input_tokens":300,"output_tokens":30,"usd":0.03,"calls":5}"#,
1338            r#"{"skill":"speckit-specify","input_tokens":200,"output_tokens":20,"usd":0.02,"calls":4}"#,
1339            r#"{"skill":"speckit-plan","input_tokens":1000,"output_tokens":100,"usd":null}"#,
1340        ]
1341        .join("\n");
1342        let one =
1343            cost_from(&rows, &["speckit-specify".to_owned()]).unwrap_or_else(|| panic!("none"));
1344        assert_eq!((one.tokens, one.runs, one.calls), (220, 3, Some(4)));
1345        assert!((one.usd.unwrap_or_default() - 0.02).abs() < 1e-9);
1346        let two = cost_from(
1347            &rows,
1348            &["speckit-specify".to_owned(), "speckit-plan".to_owned()],
1349        )
1350        .unwrap_or_else(|| panic!("none"));
1351        // A run on record without its count: no count is made up.
1352        assert_eq!(
1353            (two.tokens, two.runs, two.usd, two.calls),
1354            (1320, 1, None, None)
1355        );
1356        assert_eq!(cost_from(&rows, &["speckit-tasks".to_owned()]), None);
1357    }
1358}