1use 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
24pub(crate) fn one_line(text: &str) -> String {
26 text.split_whitespace().collect::<Vec<_>>().join(" ")
27}
28
29pub(crate) fn person(root: &Path) -> String {
31 format!("{} (asserted)", crate::approvals::who_in(root))
32}
33
34pub(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
94pub const SET_ASIDE: &str = "set_aside";
96
97#[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#[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
121fn 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#[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 Err(e) => {
194 return refuse(
195 CommandStatus::Error,
196 format!("the commit could not be made: {e}"),
197 );
198 }
199 };
200 let moved = match crate::worktree::worktree_for(&git, &format!("speccoder/{feature}")) {
203 Ok(Some(wt)) => {
204 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 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#[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
312fn 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
359fn 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
379fn 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
392pub 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
406pub 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 crate::pipeline::in_session("accept", path, feature, state, &options, |o| {
421 accept_task(path, feature, task, reason, state, o)
422 })
423}
424
425fn accept_task(
426 path: &Path,
427 feature: Option<&str>,
428 task: &str,
429 reason: &str,
430 state: &Path,
431 options: &crate::RunOptions,
432) -> CommandResult {
433 let refuse = |status, m: String| CommandResult::new("accept", status, &m);
434 let wt = crate::pipeline::workspace(path, feature, state, options);
435 let Some(CommandData::Worktree(w)) = wt.data() else {
436 return CommandResult::carried("accept", &wt);
437 };
438 if wt.status() != CommandStatus::Ok {
439 return CommandResult::carried("accept", &wt);
440 }
441 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
442 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
443 };
444 let wt_root = PathBuf::from(&wt_path);
445 if options.session.is_none() && crate::worktree::require_clean(&wt_root).is_err() {
446 return refuse(
447 CommandStatus::GitPrecondition,
448 format!(
449 "the worktree {wt_path} has uncommitted changes; the next `speccoder run` sets them aside, or commit them yourself"
450 ),
451 )
452 .with_code("WORKTREE-DIRTY");
453 }
454 let project = PathBuf::from(&project_dir);
455 let format = crate::format::at(&project);
456 let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
457 return refuse(
458 CommandStatus::NotAProject,
459 format.messages().no_project_in_worktree.to_owned(),
460 );
461 };
462 let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
463 return refuse(
464 CommandStatus::NotFound,
465 format!("{} has no {}", w.feature, format.messages().tasks_file),
466 );
467 };
468 let Some(t) = tasks.tasks.iter().find(|t| t.id == task) else {
469 return refuse(
470 CommandStatus::NotFound,
471 format!("no task {task} in {}", w.feature),
472 );
473 };
474 if t.done {
475 return refuse(
476 CommandStatus::TaskNotReady,
477 format!("{task} is already done"),
478 )
479 .with_code("TASK-DONE");
480 }
481 let mut restored = String::new();
485 if let Some(saved) = crate::attempts::list(&wt_root, &w.feature)
486 .into_iter()
487 .find(|a| a.task == task && !is_set_aside(a))
488 .filter(|a| a.outcome == "collection_unknown")
489 {
490 if options.session.is_some() {
491 return refuse(
492 CommandStatus::GitPrecondition,
493 format!(
494 "{task}'s attempt ({}) was saved and undone; in place it cannot be put back for you: restore it with `git checkout {} -- .`, then accept",
495 saved.reference, saved.reference
496 ),
497 );
498 }
499 let tree = crate::git::Git::at(&wt_root)
500 .line(&["rev-parse", &format!("{}^{{tree}}", saved.commit)])
501 .map(|t| t.trim().to_owned());
502 let put_back = tree.map_err(|e| e.0).and_then(|tree| {
503 let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
504 crate::worktree::restore(&wt_root, tmp.path(), &crate::worktree::Checkpoint(tree))
505 .map_err(|e| e.0)
506 });
507 if let Err(e) = put_back {
508 return refuse(
509 CommandStatus::Error,
510 format!(
511 "{task}'s saved attempt ({}) could not be put back: {e}",
512 saved.reference
513 ),
514 );
515 }
516 restored = format!(" with its saved attempt ({})", saved.reference);
517 }
518 let reason = one_line(reason);
519 let by = person(&project);
520 let message = format!(
521 "speccoder: {task} accepted by {by}\n\n{}\n\n{reason}\n\nSpec-Task: {}/{task}\nVerdict: attested\nAccepted-By: {by}\nReason: {reason}\n",
522 t.title, w.feature
523 );
524 let file = project.join(&tasks.path);
525 let committed = match &options.session {
526 None => format.tick(&file, task, &t.line_hash).and_then(|()| {
527 crate::commit_attempt(&wt_root, &message).map_err(|e| format!("commit failed: {e}"))
528 }),
529 Some(session) => {
531 let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
532 let Ok(mut s) = session.lock() else {
533 return refuse(
534 CommandStatus::Error,
535 "the in-place session is unavailable".to_owned(),
536 );
537 };
538 if let Err(e) = s.check(&policy) {
539 return e.result("accept");
540 }
541 let digest =
542 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
543 let done = s.commit_and_tick(
544 &plane_dir_of(state, &project),
545 &file,
546 &t.line_hash,
547 &message,
548 &digest,
549 || format.tick(&file, task, &t.line_hash),
550 );
551 match (done.commit, done.ticked) {
552 (Some(c), true) => Ok(c),
553 _ => Err(done.problems.join("; ")),
554 }
555 }
556 };
557 match committed {
558 Ok(commit) => {
559 let attested = attest_decision(
560 &wt_root,
561 &project,
562 t,
563 (
564 &w.feature,
565 &snap
566 .feature
567 .as_ref()
568 .map(|f| f.feature.dir.clone())
569 .unwrap_or_default(),
570 ),
571 (&commit, "attested"),
572 (&by, &reason),
573 );
574 done(
575 "accept",
576 ControlReport {
577 feature: w.feature.clone(),
578 task: task.to_owned(),
579 action: "accepted".to_owned(),
580 text: Some(reason),
581 record: Some(commit),
582 },
583 &format!(
584 "{task} ticked as attested by {by}{restored} (not verified); `report` shows it as such{attested}"
585 ),
586 )
587 }
588 Err(e) => refuse(CommandStatus::Error, e),
589 }
590}
591
592fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
594 crate::worktree::project_state_dir(state, project)
595}
596
597fn ref_task(task: &str) -> String {
602 task.replace('%', "%25").replace(':', "%3A")
603}
604
605fn task_of_ref(name: &str) -> String {
607 name.replace("%3A", ":").replace("%25", "%")
608}
609
610#[must_use]
612pub fn skipped(root: &Path, feature: &str) -> BTreeMap<String, String> {
613 let format = format!("--format=%(refname){FS}%(trailers:key=Reason,valueonly){RS}");
614 let prefix = format!("{SKIPPED}/{feature}/");
615 let out = Git::at(root)
616 .run(&["for-each-ref", &format, &prefix])
617 .map(|b| String::from_utf8_lossy(&b).into_owned())
618 .unwrap_or_default();
619 out.split(RS)
620 .filter_map(|r| {
621 let (name, reason) = r.trim_start_matches('\n').split_once(FS)?;
622 Some((
623 task_of_ref(name.strip_prefix(&prefix)?),
624 reason.trim().to_owned(),
625 ))
626 })
627 .collect()
628}
629
630pub fn skip(path: &Path, feature: Option<&str>, task: &str, reason: Option<&str>) -> CommandResult {
634 let located = match task.strip_prefix("bug:") {
637 Some(slug) => bug_unit_of(path, feature, slug),
638 None => locate("skip", path, feature),
639 };
640 let (feature, root) = match located {
641 Ok(x) => x,
642 Err(r) => return *r,
643 };
644 let git = Git::at(&root);
645 let reference = format!("{SKIPPED}/{feature}/{}", ref_task(task));
646 let report = |action: &str, text: Option<String>| ControlReport {
647 feature: feature.clone(),
648 task: task.to_owned(),
649 action: action.to_owned(),
650 text,
651 record: Some(reference.clone()),
652 };
653 let Some(reason) = reason else {
654 let _ = git.run(&["update-ref", "-d", &reference]);
655 return done(
656 "skip",
657 report("unskipped", None),
658 &format!("{task} is no longer skipped"),
659 );
660 };
661 if !task.starts_with("bug:") && !open_tasks(&root, &feature).iter().any(|t| t == task) {
662 return CommandResult::new(
663 "skip",
664 CommandStatus::NotFound,
665 &format!("{task} is not an open task of {feature}"),
666 );
667 }
668 if task.starts_with("bug:")
669 && let Some(state) = crate::worktree::state_dir()
670 && let Some(b) = crate::bug::state::load(
671 &crate::worktree::project_state_dir(&state, &root),
672 &feature,
673 task,
674 )
675 && b.commit.is_some()
676 {
677 return CommandResult::new(
678 "skip",
679 CommandStatus::TaskNotReady,
680 &format!("{task} is done already: its commit is recorded"),
681 )
682 .with_code("TASK-DONE");
683 }
684 let reason = one_line(reason);
685 let by = person(&root);
686 let made = git
687 .line(&["rev-parse", "HEAD^{tree}"])
688 .and_then(|tree| {
689 git.line(&[
690 "-c",
691 "user.name=speccoder",
692 "-c",
693 "user.email=speccoder@users.noreply.invalid",
694 "-c",
695 "commit.gpgsign=false",
696 "commit-tree",
697 &tree,
698 "-m",
699 &format!("speccoder: skip {task}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nSkipped-By: {by}\nReason: {reason}\n"),
700 ])
701 })
702 .and_then(|commit| git.run(&["update-ref", &reference, &commit]).map(|_| commit));
703 match made {
704 Ok(commit) => {
705 let snap = crate::format::at(&root).inspect(&root, Some(&feature));
706 let unit = snap.as_ref().and_then(|s| s.feature.as_ref());
707 let attested = unit
708 .and_then(|u| {
709 u.tasks
710 .as_ref()
711 .and_then(|t| t.tasks.iter().find(|x| x.id == task))
712 .map(|t| (u, t))
713 })
714 .map(|(u, t)| {
715 attest_decision(
716 &root,
717 &root,
718 t,
719 (&feature, &u.feature.dir),
720 (&commit, "skipped"),
721 (&by, &reason),
722 )
723 })
724 .unwrap_or_default();
725 done(
726 "skip",
727 report("skipped", Some(reason)),
728 &format!(
729 "{task} skipped: runs pass it by, and tasks depending on it stay blocked (`skip --task {task} --undo` to take it back){attested}"
730 ),
731 )
732 }
733 Err(e) => CommandResult::new("skip", CommandStatus::GitPrecondition, &e.0),
734 }
735}
736
737pub fn rule(
745 path: &Path,
746 feature: Option<&str>,
747 task: &str,
748 reason: &str,
749 variant: Option<&str>,
750 state: &Path,
751) -> CommandResult {
752 let (feature, root) = match locate("rule", path, feature) {
753 Ok(x) => x,
754 Err(r) => return *r,
755 };
756 if reason.trim().is_empty() {
757 return CommandResult::new("rule", CommandStatus::UsageError, "a ruling needs a reason");
758 }
759 let keep = crate::strength::dir(
760 &crate::worktree::project_state_dir(state, &root),
761 &feature,
762 task,
763 );
764 let Some(held) = crate::strength::held(&keep) else {
765 return CommandResult::new(
766 "rule",
767 CommandStatus::NotFound,
768 &format!("{task} has no attempt held for a ruling on its tests"),
769 )
770 .with_code("NOT-FOUND");
771 };
772 let chosen: Vec<_> = held
773 .findings
774 .iter()
775 .filter(|p| variant.is_none_or(|v| p.id == v))
776 .collect();
777 if chosen.is_empty() {
778 return CommandResult::new(
779 "rule",
780 CommandStatus::NotFound,
781 &format!(
782 "{task} has no finding `{}` (held: {})",
783 variant.unwrap_or_default(),
784 held.findings
785 .iter()
786 .map(|p| p.id.as_str())
787 .collect::<Vec<_>>()
788 .join(", ")
789 ),
790 )
791 .with_code("NOT-FOUND");
792 }
793 let by = crate::approvals::who_in(&root);
794 for p in &chosen {
795 let ruling = speccoder_core::strength::Ruling {
796 digest: p.digest.clone(),
797 scenario: p.scenario.clone(),
798 by: by.clone(),
799 reason: reason.trim().to_owned(),
800 };
801 if let Err(e) = crate::strength::add_ruling(&keep, ruling) {
802 return CommandResult::new("rule", CommandStatus::Error, &e.to_string());
803 }
804 }
805 done(
806 "rule",
807 ControlReport {
808 feature,
809 task: task.to_owned(),
810 action: "ruled_tests_right".to_owned(),
811 text: Some(reason.trim().to_owned()),
812 record: None,
813 },
814 &format!(
815 "ruled by {by}: {task}'s tests are right about {}; `speccoder run --task {task}` judges the held attempt again and ticks it",
816 chosen
817 .iter()
818 .map(|p| format!("{} ({})", p.id, p.scenario))
819 .collect::<Vec<_>>()
820 .join(", ")
821 ),
822 )
823}
824
825pub fn nudge_with(
828 path: &Path,
829 feature: Option<&str>,
830 task: &str,
831 note: Option<&str>,
832 state: &Path,
833) -> CommandResult {
834 let (feature, root) = match locate("nudge", path, feature) {
835 Ok(x) => x,
836 Err(r) => return *r,
837 };
838 let dir = crate::worktree::project_state_dir(state, &root);
839 let mut decisions = match crate::approvals::Decisions::load(&dir) {
840 Ok(d) => d,
841 Err(e) => return CommandResult::new("nudge", CommandStatus::ConfigError, &e),
842 };
843 let entry = decisions
844 .tasks
845 .entry(format!("{feature}/{task}"))
846 .or_default();
847 let (action, message) = match note {
848 Some(n) => {
849 entry.nudges.push(n.trim().to_owned());
850 (
851 "nudged",
852 format!("{task}'s next attempts will read: {}", n.trim()),
853 )
854 }
855 None => {
856 entry.nudges.clear();
857 ("nudges_cleared", format!("{task} has no notes any more"))
858 }
859 };
860 if let Err(e) = decisions.save(&dir) {
861 return CommandResult::new("nudge", CommandStatus::Error, &e);
862 }
863 done(
864 "nudge",
865 ControlReport {
866 feature,
867 task: task.to_owned(),
868 action: action.to_owned(),
869 text: note.map(|n| n.trim().to_owned()),
870 record: None,
871 },
872 &message,
873 )
874}
875
876fn add(total: &mut Usage, u: &serde_json::Value) {
879 let n = |k: &str| u[k].as_u64().unwrap_or(0);
880 total.input_tokens += n("input_tokens");
881 total.output_tokens += n("output_tokens");
882 total.cache_read_tokens += n("cache_read_tokens");
883 total.cache_write_tokens += n("cache_write_tokens");
884 if let Some(c) = u["cost_usd"].as_f64() {
885 total.usd = Some(total.usd.unwrap_or(0.0) + c);
886 }
887}
888
889fn price(u: &mut Usage, prices: Option<crate::substrate::model_config::Prices>) {
892 if let Some(p) = prices.filter(|_| u.usd.is_none()) {
893 u.usd = Some(p.usd(
894 u.input_tokens,
895 u.output_tokens,
896 u.cache_read_tokens,
897 u.cache_write_tokens,
898 ));
899 }
900}
901
902pub fn cost(path: &Path, feature: Option<&str>) -> CommandResult {
906 let (feature, root) = match locate("cost", path, feature) {
907 Ok(x) => x,
908 Err(r) => return *r,
909 };
910 let model = crate::verify::find_config(&root)
911 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
912 .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten());
913 let prices = model.as_ref().and_then(|m| m.prices);
914
915 let branch = format!("speccoder/{feature}");
916 let format = format!(
917 "--format=%s{FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
918 );
919 let log = Git::at(&root)
920 .run(&["log", &format, &branch])
921 .map(|b| String::from_utf8_lossy(&b).into_owned())
922 .unwrap_or_default();
923 let mut per_task: BTreeMap<String, (usize, Usage)> = BTreeMap::new();
924 let mut convergence = Usage::default();
925 for record in log.split(RS) {
926 let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
927 let [subject, task, usage] = f[..] else {
928 continue;
929 };
930 let Some(u) = crate::attempts::parse_usage(usage.trim()) else {
931 continue;
932 };
933 if let Some(t) = task.trim().strip_prefix(&format!("{feature}/")) {
934 let e = per_task.entry(t.to_owned()).or_default();
935 e.0 += 1;
936 add(&mut e.1, &u);
937 } else if subject.starts_with("speccoder: converge round") {
938 add(&mut convergence, &u);
939 }
940 }
941 for saved in crate::attempts::list(&root, &feature) {
942 if let Some(u) = saved.usage.as_ref() {
943 let e = per_task.entry(saved.task.clone()).or_default();
944 e.0 += 1;
945 add(&mut e.1, u);
946 }
947 }
948
949 let mut total = Usage::default();
950 let mut tasks = Vec::new();
951 for (task, (attempts, mut usage)) in per_task {
952 total.input_tokens += usage.input_tokens;
953 total.output_tokens += usage.output_tokens;
954 total.cache_read_tokens += usage.cache_read_tokens;
955 total.cache_write_tokens += usage.cache_write_tokens;
956 price(&mut usage, prices);
957 tasks.push(TaskCost {
958 task,
959 attempts,
960 usage,
961 });
962 }
963 total.input_tokens += convergence.input_tokens;
964 total.output_tokens += convergence.output_tokens;
965 total.cache_read_tokens += convergence.cache_read_tokens;
966 total.cache_write_tokens += convergence.cache_write_tokens;
967 price(&mut convergence, prices);
968 let priced: Vec<f64> = tasks
969 .iter()
970 .map(|t| &t.usage)
971 .chain([&convergence])
972 .filter_map(|u| u.usd)
973 .collect();
974 total.usd = (!priced.is_empty()).then(|| priced.iter().sum());
975 let message = match total.usd {
976 Some(usd) => format!(
977 "{} tokens in, {} out ({} from the cache): ${usd:.4}",
978 total.input_tokens, total.output_tokens, total.cache_read_tokens
979 ),
980 None => format!(
981 "{} tokens in, {} out ({} from the cache); add price_input_per_mtok and price_output_per_mtok to [model] for money",
982 total.input_tokens, total.output_tokens, total.cache_read_tokens
983 ),
984 };
985 CommandResult::new("cost", CommandStatus::Ok, &message).with_data(CommandData::Cost(Box::new(
986 CostReport {
987 feature,
988 tasks,
989 convergence,
990 total,
991 priced_model: prices.and(model.map(|m| format!("{}/{}", m.provider, m.model))),
992 },
993 )))
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 #[test]
1001 fn cached_input_is_priced_at_the_cache_rate() {
1002 let mut u = Usage {
1003 input_tokens: 1_000_000,
1004 output_tokens: 100_000,
1005 cache_read_tokens: 600_000,
1006 cache_write_tokens: 0,
1007 usd: None,
1008 };
1009 price(
1010 &mut u,
1011 Some(crate::substrate::model_config::Prices {
1012 input: 1.0,
1013 output: 4.0,
1014 cache_read: 0.1,
1015 cache_write: 1.25,
1016 cache_write_1h: 2.0,
1017 one_hour: false,
1018 }),
1019 );
1020 assert!(
1022 (u.usd.unwrap_or_default() - 0.86).abs() < 1e-9,
1023 "{:?}",
1024 u.usd
1025 );
1026 }
1027}
1028
1029const SKILL_USAGE: &str = "skills.jsonl";
1032
1033pub(crate) fn record_skill_usage(
1035 plane: &Path,
1036 skill: &str,
1037 [input, output, cache_read, cache_write]: [u64; 4],
1038 prices: Option<crate::substrate::model_config::Prices>,
1039 calls: u64,
1040) {
1041 use std::io::Write as _;
1042 if input + output == 0 {
1043 return;
1044 }
1045 let usd = crate::progress::usd(prices, input, output, cache_read, cache_write);
1046 let line = serde_json::json!({
1047 "skill": skill,
1048 "input_tokens": input,
1049 "output_tokens": output,
1050 "usd": usd,
1051 "calls": calls,
1052 });
1053 if let Ok(mut f) = std::fs::OpenOptions::new()
1054 .create(true)
1055 .append(true)
1056 .open(plane.join(SKILL_USAGE))
1057 {
1058 let _ = writeln!(f, "{line}");
1059 }
1060}
1061
1062#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1066pub struct SkillCost {
1067 pub tokens: u64,
1069 pub calls: Option<u64>,
1071 pub usd: Option<f64>,
1073 pub runs: usize,
1075}
1076
1077#[must_use]
1079pub fn skill_cost(root: &Path, skills: &[String]) -> (Option<SkillCost>, Option<String>) {
1080 let model = crate::verify::find_config(root)
1081 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1082 .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten())
1083 .map(|m| m.model);
1084 let Some(state) = crate::worktree::state_dir() else {
1085 return (None, model);
1086 };
1087 let plane = crate::worktree::project_state_dir(&state, root);
1088 let text = std::fs::read_to_string(plane.join(SKILL_USAGE)).unwrap_or_default();
1089 (cost_from(&text, skills), model)
1090}
1091
1092fn cost_from(text: &str, skills: &[String]) -> Option<SkillCost> {
1094 let rows: Vec<serde_json::Value> = text
1095 .lines()
1096 .filter_map(|l| serde_json::from_str(l).ok())
1097 .collect();
1098 let median = |mut v: Vec<f64>| -> f64 {
1099 v.sort_by(f64::total_cmp);
1100 let n = v.len();
1101 if n % 2 == 1 {
1102 v[n / 2]
1103 } else {
1104 (v[n / 2 - 1] + v[n / 2]) / 2.0
1105 }
1106 };
1107 let mut tokens = 0.0;
1108 let mut calls = Some(0.0);
1109 let mut usd = Some(0.0);
1110 let mut runs = usize::MAX;
1111 for skill in skills {
1112 let mine: Vec<&serde_json::Value> = rows
1113 .iter()
1114 .filter(|r| r["skill"] == skill.as_str())
1115 .collect();
1116 if mine.is_empty() {
1117 return None;
1118 }
1119 runs = runs.min(mine.len());
1120 #[allow(clippy::cast_precision_loss)]
1121 let t: Vec<f64> = mine
1122 .iter()
1123 .map(|r| {
1124 (r["input_tokens"].as_u64().unwrap_or(0) + r["output_tokens"].as_u64().unwrap_or(0))
1125 as f64
1126 })
1127 .collect();
1128 tokens += median(t);
1129 #[allow(clippy::cast_precision_loss)]
1130 let counted: Vec<f64> = mine
1131 .iter()
1132 .filter_map(|r| r["calls"].as_u64())
1133 .map(|c| c as f64)
1134 .collect();
1135 calls = match calls {
1136 Some(sum) if counted.len() == mine.len() => Some(sum + median(counted)),
1137 _ => None,
1138 };
1139 let priced: Vec<f64> = mine.iter().filter_map(|r| r["usd"].as_f64()).collect();
1140 usd = match usd {
1141 Some(sum) if priced.len() == mine.len() => Some(sum + median(priced)),
1142 _ => None,
1143 };
1144 }
1145 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1146 let cost = SkillCost {
1147 tokens: tokens.round() as u64,
1148 calls: calls.map(|c| c.round() as u64),
1149 usd,
1150 runs,
1151 };
1152 Some(cost)
1153}
1154
1155#[cfg(test)]
1156mod skill_cost_tests {
1157 use super::*;
1158
1159 #[test]
1160 fn the_cost_line_is_the_median_of_runs_on_record_or_nothing() {
1161 let rows = [
1162 r#"{"skill":"speckit-specify","input_tokens":100,"output_tokens":10,"usd":0.01,"calls":3}"#,
1163 r#"{"skill":"speckit-specify","input_tokens":300,"output_tokens":30,"usd":0.03,"calls":5}"#,
1164 r#"{"skill":"speckit-specify","input_tokens":200,"output_tokens":20,"usd":0.02,"calls":4}"#,
1165 r#"{"skill":"speckit-plan","input_tokens":1000,"output_tokens":100,"usd":null}"#,
1166 ]
1167 .join("\n");
1168 let one =
1169 cost_from(&rows, &["speckit-specify".to_owned()]).unwrap_or_else(|| panic!("none"));
1170 assert_eq!((one.tokens, one.runs, one.calls), (220, 3, Some(4)));
1171 assert!((one.usd.unwrap_or_default() - 0.02).abs() < 1e-9);
1172 let two = cost_from(
1173 &rows,
1174 &["speckit-specify".to_owned(), "speckit-plan".to_owned()],
1175 )
1176 .unwrap_or_else(|| panic!("none"));
1177 assert_eq!(
1179 (two.tokens, two.runs, two.usd, two.calls),
1180 (1320, 1, None, None)
1181 );
1182 assert_eq!(cost_from(&rows, &["speckit-tasks".to_owned()]), None);
1183 }
1184}