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