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 accept_in_session(path, feature, task, reason, state, &options)
421}
422
423#[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 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 let reason = one_line(reason);
533 let by = person(&project);
534 let message = format!(
535 "speccoder: {task} accepted by {by}\n\n{}\n\n{reason}\n\nSpec-Task: {}/{task}\nVerdict: attested\nAccepted-By: {by}\nReason: {reason}\n",
536 t.title, w.feature
537 );
538 let file = project.join(&tasks.path);
539 let committed = match &options.session {
540 None => format.tick(&file, task, &t.line_hash).and_then(|()| {
541 crate::commit_attempt(&wt_root, &message).map_err(|e| format!("commit failed: {e}"))
542 }),
543 Some(session) => {
545 let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
546 let Ok(mut s) = session.lock() else {
547 return refuse(
548 CommandStatus::Error,
549 "the in-place session is unavailable".to_owned(),
550 );
551 };
552 if let Err(e) = s.check(&policy) {
553 return e.result("accept");
554 }
555 let digest =
556 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
557 let done = s.commit_and_tick(
558 &plane_dir_of(state, &project),
559 &file,
560 &t.line_hash,
561 &message,
562 &digest,
563 || format.tick(&file, task, &t.line_hash),
564 );
565 match (done.commit, done.ticked) {
566 (Some(c), true) => Ok(c),
567 _ => Err(done.problems.join("; ")),
568 }
569 }
570 };
571 match committed {
572 Ok(commit) => {
573 let attested = attest_decision(
574 &wt_root,
575 &project,
576 t,
577 (
578 &w.feature,
579 &snap
580 .feature
581 .as_ref()
582 .map(|f| f.feature.dir.clone())
583 .unwrap_or_default(),
584 ),
585 (&commit, "attested"),
586 (&by, &reason),
587 );
588 done(
589 "accept",
590 ControlReport {
591 feature: w.feature.clone(),
592 task: task.to_owned(),
593 action: "accepted".to_owned(),
594 text: Some(reason),
595 record: Some(commit),
596 },
597 &format!(
598 "{task} ticked as attested by {by}{restored} (not verified); `report` shows it as such{attested}"
599 ),
600 )
601 }
602 Err(e) => refuse(CommandStatus::Error, e),
603 }
604}
605
606fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
608 crate::worktree::project_state_dir(state, project)
609}
610
611fn ref_task(task: &str) -> String {
616 task.replace('%', "%25").replace(':', "%3A")
617}
618
619fn task_of_ref(name: &str) -> String {
621 name.replace("%3A", ":").replace("%25", "%")
622}
623
624#[must_use]
626pub fn skipped(root: &Path, feature: &str) -> BTreeMap<String, String> {
627 let format = format!("--format=%(refname){FS}%(trailers:key=Reason,valueonly){RS}");
628 let prefix = format!("{SKIPPED}/{feature}/");
629 let out = Git::at(root)
630 .run(&["for-each-ref", &format, &prefix])
631 .map(|b| String::from_utf8_lossy(&b).into_owned())
632 .unwrap_or_default();
633 out.split(RS)
634 .filter_map(|r| {
635 let (name, reason) = r.trim_start_matches('\n').split_once(FS)?;
636 Some((
637 task_of_ref(name.strip_prefix(&prefix)?),
638 reason.trim().to_owned(),
639 ))
640 })
641 .collect()
642}
643
644pub fn skip(path: &Path, feature: Option<&str>, task: &str, reason: Option<&str>) -> CommandResult {
648 let located = match task.strip_prefix("bug:") {
651 Some(slug) => bug_unit_of(path, feature, slug),
652 None => locate("skip", path, feature),
653 };
654 let (feature, root) = match located {
655 Ok(x) => x,
656 Err(r) => return *r,
657 };
658 let git = Git::at(&root);
659 let reference = format!("{SKIPPED}/{feature}/{}", ref_task(task));
660 let report = |action: &str, text: Option<String>| ControlReport {
661 feature: feature.clone(),
662 task: task.to_owned(),
663 action: action.to_owned(),
664 text,
665 record: Some(reference.clone()),
666 };
667 let Some(reason) = reason else {
668 let _ = git.run(&["update-ref", "-d", &reference]);
669 return done(
670 "skip",
671 report("unskipped", None),
672 &format!("{task} is no longer skipped"),
673 );
674 };
675 if !task.starts_with("bug:") && !open_tasks(&root, &feature).iter().any(|t| t == task) {
676 return CommandResult::new(
677 "skip",
678 CommandStatus::NotFound,
679 &format!("{task} is not an open task of {feature}"),
680 );
681 }
682 if task.starts_with("bug:")
683 && let Some(state) = crate::worktree::state_dir()
684 && let Some(b) = crate::bug::state::load(
685 &crate::worktree::project_state_dir(&state, &root),
686 &feature,
687 task,
688 )
689 && b.commit.is_some()
690 {
691 return CommandResult::new(
692 "skip",
693 CommandStatus::TaskNotReady,
694 &format!("{task} is done already: its commit is recorded"),
695 )
696 .with_code("TASK-DONE");
697 }
698 let reason = one_line(reason);
699 let by = person(&root);
700 let made = git
701 .line(&["rev-parse", "HEAD^{tree}"])
702 .and_then(|tree| {
703 git.line(&[
704 "-c",
705 "user.name=speccoder",
706 "-c",
707 "user.email=speccoder@users.noreply.invalid",
708 "-c",
709 "commit.gpgsign=false",
710 "commit-tree",
711 &tree,
712 "-m",
713 &format!("speccoder: skip {task}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nSkipped-By: {by}\nReason: {reason}\n"),
714 ])
715 })
716 .and_then(|commit| git.run(&["update-ref", &reference, &commit]).map(|_| commit));
717 match made {
718 Ok(commit) => {
719 let snap = crate::format::at(&root).inspect(&root, Some(&feature));
720 let unit = snap.as_ref().and_then(|s| s.feature.as_ref());
721 let attested = unit
722 .and_then(|u| {
723 u.tasks
724 .as_ref()
725 .and_then(|t| t.tasks.iter().find(|x| x.id == task))
726 .map(|t| (u, t))
727 })
728 .map(|(u, t)| {
729 attest_decision(
730 &root,
731 &root,
732 t,
733 (&feature, &u.feature.dir),
734 (&commit, "skipped"),
735 (&by, &reason),
736 )
737 })
738 .unwrap_or_default();
739 done(
740 "skip",
741 report("skipped", Some(reason)),
742 &format!(
743 "{task} skipped: runs pass it by, and tasks depending on it stay blocked (`skip --task {task} --undo` to take it back){attested}"
744 ),
745 )
746 }
747 Err(e) => CommandResult::new("skip", CommandStatus::GitPrecondition, &e.0),
748 }
749}
750
751pub fn rule(
759 path: &Path,
760 feature: Option<&str>,
761 task: &str,
762 reason: &str,
763 variant: Option<&str>,
764 state: &Path,
765) -> CommandResult {
766 let (feature, root) = match locate("rule", path, feature) {
767 Ok(x) => x,
768 Err(r) => return *r,
769 };
770 if reason.trim().is_empty() {
771 return CommandResult::new("rule", CommandStatus::UsageError, "a ruling needs a reason");
772 }
773 let keep = crate::strength::dir(
774 &crate::worktree::project_state_dir(state, &root),
775 &feature,
776 task,
777 );
778 let Some(held) = crate::strength::held(&keep) else {
779 return CommandResult::new(
780 "rule",
781 CommandStatus::NotFound,
782 &format!("{task} has no attempt held for a ruling on its tests"),
783 )
784 .with_code("NOT-FOUND");
785 };
786 let chosen: Vec<_> = held
787 .findings
788 .iter()
789 .filter(|p| variant.is_none_or(|v| p.id == v))
790 .collect();
791 if chosen.is_empty() {
792 return CommandResult::new(
793 "rule",
794 CommandStatus::NotFound,
795 &format!(
796 "{task} has no finding `{}` (held: {})",
797 variant.unwrap_or_default(),
798 held.findings
799 .iter()
800 .map(|p| p.id.as_str())
801 .collect::<Vec<_>>()
802 .join(", ")
803 ),
804 )
805 .with_code("NOT-FOUND");
806 }
807 let by = crate::approvals::who_in(&root);
808 for p in &chosen {
809 let ruling = speccoder_core::strength::Ruling {
810 digest: p.digest.clone(),
811 scenario: p.scenario.clone(),
812 by: by.clone(),
813 reason: reason.trim().to_owned(),
814 };
815 if let Err(e) = crate::strength::add_ruling(&keep, ruling) {
816 return CommandResult::new("rule", CommandStatus::Error, &e.to_string());
817 }
818 }
819 done(
820 "rule",
821 ControlReport {
822 feature,
823 task: task.to_owned(),
824 action: "ruled_tests_right".to_owned(),
825 text: Some(reason.trim().to_owned()),
826 record: None,
827 },
828 &format!(
829 "ruled by {by}: {task}'s tests are right about {}; `speccoder run --task {task}` judges the held attempt again and ticks it",
830 chosen
831 .iter()
832 .map(|p| format!("{} ({})", p.id, p.scenario))
833 .collect::<Vec<_>>()
834 .join(", ")
835 ),
836 )
837}
838
839pub fn nudge_with(
842 path: &Path,
843 feature: Option<&str>,
844 task: &str,
845 note: Option<&str>,
846 state: &Path,
847) -> CommandResult {
848 let (feature, root) = match locate("nudge", path, feature) {
849 Ok(x) => x,
850 Err(r) => return *r,
851 };
852 let dir = crate::worktree::project_state_dir(state, &root);
853 let mut decisions = match crate::approvals::Decisions::load(&dir) {
854 Ok(d) => d,
855 Err(e) => return CommandResult::new("nudge", CommandStatus::ConfigError, &e),
856 };
857 let entry = decisions
858 .tasks
859 .entry(format!("{feature}/{task}"))
860 .or_default();
861 let (action, message) = match note {
862 Some(n) => {
863 entry.nudges.push(n.trim().to_owned());
864 (
865 "nudged",
866 format!("{task}'s next attempts will read: {}", n.trim()),
867 )
868 }
869 None => {
870 entry.nudges.clear();
871 ("nudges_cleared", format!("{task} has no notes any more"))
872 }
873 };
874 if let Err(e) = decisions.save(&dir) {
875 return CommandResult::new("nudge", CommandStatus::Error, &e);
876 }
877 done(
878 "nudge",
879 ControlReport {
880 feature,
881 task: task.to_owned(),
882 action: action.to_owned(),
883 text: note.map(|n| n.trim().to_owned()),
884 record: None,
885 },
886 &message,
887 )
888}
889
890fn add(total: &mut Usage, u: &serde_json::Value) {
893 let n = |k: &str| u[k].as_u64().unwrap_or(0);
894 total.input_tokens += n("input_tokens");
895 total.output_tokens += n("output_tokens");
896 total.cache_read_tokens += n("cache_read_tokens");
897 total.cache_write_tokens += n("cache_write_tokens");
898 if let Some(c) = u["cost_usd"].as_f64() {
899 total.usd = Some(total.usd.unwrap_or(0.0) + c);
900 }
901}
902
903fn price(u: &mut Usage, prices: Option<crate::substrate::model_config::Prices>) {
906 if let Some(p) = prices.filter(|_| u.usd.is_none()) {
907 u.usd = Some(p.usd(
908 u.input_tokens,
909 u.output_tokens,
910 u.cache_read_tokens,
911 u.cache_write_tokens,
912 ));
913 }
914}
915
916pub fn cost(path: &Path, feature: Option<&str>) -> CommandResult {
920 let (feature, root) = match locate("cost", path, feature) {
921 Ok(x) => x,
922 Err(r) => return *r,
923 };
924 let model = crate::verify::find_config(&root)
925 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
926 .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten());
927 let prices = model.as_ref().and_then(|m| m.prices);
928
929 let branch = format!("speccoder/{feature}");
930 let format = format!(
931 "--format=%s{FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Usage,valueonly){RS}"
932 );
933 let log = Git::at(&root)
934 .run(&["log", &format, &branch])
935 .map(|b| String::from_utf8_lossy(&b).into_owned())
936 .unwrap_or_default();
937 let mut per_task: BTreeMap<String, (usize, Usage)> = BTreeMap::new();
938 let mut convergence = Usage::default();
939 for record in log.split(RS) {
940 let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
941 let [subject, task, usage] = f[..] else {
942 continue;
943 };
944 let Some(u) = crate::attempts::parse_usage(usage.trim()) else {
945 continue;
946 };
947 if let Some(t) = task.trim().strip_prefix(&format!("{feature}/")) {
948 let e = per_task.entry(t.to_owned()).or_default();
949 e.0 += 1;
950 add(&mut e.1, &u);
951 } else if subject.starts_with("speccoder: converge round") {
952 add(&mut convergence, &u);
953 }
954 }
955 for saved in crate::attempts::list(&root, &feature) {
956 if let Some(u) = saved.usage.as_ref() {
957 let e = per_task.entry(saved.task.clone()).or_default();
958 e.0 += 1;
959 add(&mut e.1, u);
960 }
961 }
962
963 let mut total = Usage::default();
964 let mut tasks = Vec::new();
965 for (task, (attempts, mut usage)) in per_task {
966 total.input_tokens += usage.input_tokens;
967 total.output_tokens += usage.output_tokens;
968 total.cache_read_tokens += usage.cache_read_tokens;
969 total.cache_write_tokens += usage.cache_write_tokens;
970 price(&mut usage, prices);
971 tasks.push(TaskCost {
972 task,
973 attempts,
974 usage,
975 });
976 }
977 total.input_tokens += convergence.input_tokens;
978 total.output_tokens += convergence.output_tokens;
979 total.cache_read_tokens += convergence.cache_read_tokens;
980 total.cache_write_tokens += convergence.cache_write_tokens;
981 price(&mut convergence, prices);
982 let priced: Vec<f64> = tasks
983 .iter()
984 .map(|t| &t.usage)
985 .chain([&convergence])
986 .filter_map(|u| u.usd)
987 .collect();
988 total.usd = (!priced.is_empty()).then(|| priced.iter().sum());
989 let asked = crate::ask::spent(&root, None, &feature);
990 let mut ask = Usage {
991 input_tokens: asked.input,
992 output_tokens: asked.output,
993 cache_read_tokens: asked.cache_read,
994 ..Usage::default()
995 };
996 price(&mut ask, prices);
997 let message = match total.usd {
998 Some(usd) => format!(
999 "{} tokens in, {} out ({} from the cache): ${usd:.4}",
1000 total.input_tokens, total.output_tokens, total.cache_read_tokens
1001 ),
1002 None => format!(
1003 "{} tokens in, {} out ({} from the cache); add price_input_per_mtok and price_output_per_mtok to [model] for money",
1004 total.input_tokens, total.output_tokens, total.cache_read_tokens
1005 ),
1006 };
1007 CommandResult::new("cost", CommandStatus::Ok, &message).with_data(CommandData::Cost(Box::new(
1008 CostReport {
1009 feature,
1010 tasks,
1011 convergence,
1012 total,
1013 priced_model: prices.and(model.map(|m| format!("{}/{}", m.provider, m.model))),
1014 ask,
1015 },
1016 )))
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022
1023 #[test]
1024 fn cached_input_is_priced_at_the_cache_rate() {
1025 let mut u = Usage {
1026 input_tokens: 1_000_000,
1027 output_tokens: 100_000,
1028 cache_read_tokens: 600_000,
1029 cache_write_tokens: 0,
1030 usd: None,
1031 };
1032 price(
1033 &mut u,
1034 Some(crate::substrate::model_config::Prices {
1035 input: 1.0,
1036 output: 4.0,
1037 cache_read: 0.1,
1038 cache_write: 1.25,
1039 cache_write_1h: 2.0,
1040 one_hour: false,
1041 }),
1042 );
1043 assert!(
1045 (u.usd.unwrap_or_default() - 0.86).abs() < 1e-9,
1046 "{:?}",
1047 u.usd
1048 );
1049 }
1050}
1051
1052const SKILL_USAGE: &str = "skills.jsonl";
1055
1056pub(crate) fn record_skill_usage(
1058 plane: &Path,
1059 skill: &str,
1060 [input, output, cache_read, cache_write]: [u64; 4],
1061 prices: Option<crate::substrate::model_config::Prices>,
1062 calls: u64,
1063) {
1064 use std::io::Write as _;
1065 if input + output == 0 {
1066 return;
1067 }
1068 let usd = crate::progress::usd(prices, input, output, cache_read, cache_write);
1069 let line = serde_json::json!({
1070 "skill": skill,
1071 "input_tokens": input,
1072 "output_tokens": output,
1073 "usd": usd,
1074 "calls": calls,
1075 });
1076 if let Ok(mut f) = std::fs::OpenOptions::new()
1077 .create(true)
1078 .append(true)
1079 .open(plane.join(SKILL_USAGE))
1080 {
1081 let _ = writeln!(f, "{line}");
1082 }
1083}
1084
1085#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1089pub struct SkillCost {
1090 pub tokens: u64,
1092 pub calls: Option<u64>,
1094 pub usd: Option<f64>,
1096 pub runs: usize,
1098}
1099
1100#[must_use]
1102pub fn skill_cost(root: &Path, skills: &[String]) -> (Option<SkillCost>, Option<String>) {
1103 let model = crate::verify::find_config(root)
1104 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1105 .and_then(|t| crate::substrate::model_config::parse(&t).ok().flatten())
1106 .map(|m| m.model);
1107 let Some(state) = crate::worktree::state_dir() else {
1108 return (None, model);
1109 };
1110 let plane = crate::worktree::project_state_dir(&state, root);
1111 let text = std::fs::read_to_string(plane.join(SKILL_USAGE)).unwrap_or_default();
1112 (cost_from(&text, skills), model)
1113}
1114
1115fn cost_from(text: &str, skills: &[String]) -> Option<SkillCost> {
1117 let rows: Vec<serde_json::Value> = text
1118 .lines()
1119 .filter_map(|l| serde_json::from_str(l).ok())
1120 .collect();
1121 let median = |mut v: Vec<f64>| -> f64 {
1122 v.sort_by(f64::total_cmp);
1123 let n = v.len();
1124 if n % 2 == 1 {
1125 v[n / 2]
1126 } else {
1127 (v[n / 2 - 1] + v[n / 2]) / 2.0
1128 }
1129 };
1130 let mut tokens = 0.0;
1131 let mut calls = Some(0.0);
1132 let mut usd = Some(0.0);
1133 let mut runs = usize::MAX;
1134 for skill in skills {
1135 let mine: Vec<&serde_json::Value> = rows
1136 .iter()
1137 .filter(|r| r["skill"] == skill.as_str())
1138 .collect();
1139 if mine.is_empty() {
1140 return None;
1141 }
1142 runs = runs.min(mine.len());
1143 #[allow(clippy::cast_precision_loss)]
1144 let t: Vec<f64> = mine
1145 .iter()
1146 .map(|r| {
1147 (r["input_tokens"].as_u64().unwrap_or(0) + r["output_tokens"].as_u64().unwrap_or(0))
1148 as f64
1149 })
1150 .collect();
1151 tokens += median(t);
1152 #[allow(clippy::cast_precision_loss)]
1153 let counted: Vec<f64> = mine
1154 .iter()
1155 .filter_map(|r| r["calls"].as_u64())
1156 .map(|c| c as f64)
1157 .collect();
1158 calls = match calls {
1159 Some(sum) if counted.len() == mine.len() => Some(sum + median(counted)),
1160 _ => None,
1161 };
1162 let priced: Vec<f64> = mine.iter().filter_map(|r| r["usd"].as_f64()).collect();
1163 usd = match usd {
1164 Some(sum) if priced.len() == mine.len() => Some(sum + median(priced)),
1165 _ => None,
1166 };
1167 }
1168 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1169 let cost = SkillCost {
1170 tokens: tokens.round() as u64,
1171 calls: calls.map(|c| c.round() as u64),
1172 usd,
1173 runs,
1174 };
1175 Some(cost)
1176}
1177
1178#[cfg(test)]
1179mod skill_cost_tests {
1180 use super::*;
1181
1182 #[test]
1183 fn the_cost_line_is_the_median_of_runs_on_record_or_nothing() {
1184 let rows = [
1185 r#"{"skill":"speckit-specify","input_tokens":100,"output_tokens":10,"usd":0.01,"calls":3}"#,
1186 r#"{"skill":"speckit-specify","input_tokens":300,"output_tokens":30,"usd":0.03,"calls":5}"#,
1187 r#"{"skill":"speckit-specify","input_tokens":200,"output_tokens":20,"usd":0.02,"calls":4}"#,
1188 r#"{"skill":"speckit-plan","input_tokens":1000,"output_tokens":100,"usd":null}"#,
1189 ]
1190 .join("\n");
1191 let one =
1192 cost_from(&rows, &["speckit-specify".to_owned()]).unwrap_or_else(|| panic!("none"));
1193 assert_eq!((one.tokens, one.runs, one.calls), (220, 3, Some(4)));
1194 assert!((one.usd.unwrap_or_default() - 0.02).abs() < 1e-9);
1195 let two = cost_from(
1196 &rows,
1197 &["speckit-specify".to_owned(), "speckit-plan".to_owned()],
1198 )
1199 .unwrap_or_else(|| panic!("none"));
1200 assert_eq!(
1202 (two.tokens, two.runs, two.usd, two.calls),
1203 (1320, 1, None, None)
1204 );
1205 assert_eq!(cost_from(&rows, &["speckit-tasks".to_owned()]), None);
1206 }
1207}