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 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 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
633fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
635 crate::worktree::project_state_dir(state, project)
636}
637
638fn ref_task(task: &str) -> String {
643 task.replace('%', "%25").replace(':', "%3A")
644}
645
646fn task_of_ref(name: &str) -> String {
648 name.replace("%3A", ":").replace("%25", "%")
649}
650
651#[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
671pub fn skip(path: &Path, feature: Option<&str>, task: &str, reason: Option<&str>) -> CommandResult {
675 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
778pub 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
866pub 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
917fn 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
930fn 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
943pub 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 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#[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#[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 assert!(
1196 (u.usd.unwrap_or_default() - 0.86).abs() < 1e-9,
1197 "{:?}",
1198 u.usd
1199 );
1200 }
1201}
1202
1203const SKILL_USAGE: &str = "skills.jsonl";
1206
1207pub(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#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1240pub struct SkillCost {
1241 pub tokens: u64,
1243 pub calls: Option<u64>,
1245 pub usd: Option<f64>,
1247 pub runs: usize,
1249}
1250
1251#[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
1266fn 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 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}