1use std::path::Path;
4use std::sync::Arc;
5
6use speccoder_core::{CommandData, CommandResult, CommandStatus};
7
8use crate::pipeline::*;
9use crate::{controls, convergence, hosting, progress, substrate, worktree};
10
11#[must_use]
15pub fn run_feature(
16 path: &Path,
17 feature: Option<&str>,
18 options: &RunOptions,
19 converge: bool,
20) -> CommandResult {
21 with_driver("run", path, feature, |state, provider, key| {
22 if converge {
23 run_converged_with(path, feature, state, provider, Some(key), options)
24 } else {
25 run_feature_with(path, feature, state, provider, Some(key), options)
26 }
27 })
28}
29
30#[must_use]
33pub fn converge(path: &Path, feature: Option<&str>) -> CommandResult {
34 converge_in(path, feature, false)
35}
36
37#[must_use]
39pub fn converge_in(path: &Path, feature: Option<&str>, in_place: bool) -> CommandResult {
40 let options = RunOptions {
41 in_place,
42 ..RunOptions::default()
43 };
44 with_driver("converge", path, feature, |state, provider, key| {
45 convergence::converge_with_options(
46 path,
47 feature,
48 state,
49 Arc::clone(provider),
50 Some(key),
51 &options,
52 )
53 })
54}
55
56pub(crate) fn with_driver(
59 command: &str,
60 path: &Path,
61 feature: Option<&str>,
62 f: impl FnOnce(&Path, &Arc<dyn agentplane::model::ModelProvider>, &str) -> CommandResult,
63) -> CommandResult {
64 let Some(state) = worktree::state_dir() else {
65 return CommandResult::new(
66 command,
67 CommandStatus::ConfigError,
68 "no state directory (set SPECCODER_STATE_DIR or HOME)",
69 )
70 .with_code("NO-STATE-DIR");
71 };
72 let model = match setup(path, feature, &state, command) {
73 Ok(s) => s.model,
74 Err(r) => return *r,
75 };
76 let (driver, key) = match substrate::model_config::open_driver(&model) {
77 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
78 Err(e) => return substrate::model_config::refused(command, &e),
79 };
80 match driver {
81 Ok(provider) => f(&state, &provider, &key),
82 Err(e) => CommandResult::new(command, CommandStatus::ConfigError, &redact(&e, Some(&key))),
83 }
84}
85
86pub fn run_converged_with(
90 path: &Path,
91 feature: Option<&str>,
92 state: &Path,
93 provider: &Arc<dyn agentplane::model::ModelProvider>,
94 secret: Option<&str>,
95 options: &RunOptions,
96) -> CommandResult {
97 if options.in_place && options.session.is_none() {
99 return in_session("run", path, feature, state, options, |o| {
100 run_converged_with(path, feature, state, provider, secret, o)
101 });
102 }
103 let mut attempts = Vec::new();
104 let mut rounds = Vec::new();
105 let cap = match cap_of(path, options) {
106 Ok(c) => c,
107 Err(stop) => return *stop,
108 };
109 loop {
110 let round = RunOptions {
112 cap: cap.map(|c| c.cap.less(spent(&c, &attempts))),
113 ..options.clone()
114 };
115 let res = run_feature_with(path, feature, state, provider, secret, &round);
116 let Some(CommandData::Feature(fr)) = res.data() else {
117 return res;
118 };
119 let mut fr = (**fr).clone();
120 attempts.append(&mut fr.attempts);
121 let finished = res.status() == CommandStatus::Ok && fr.stop.starts_with("feature done");
122 if !finished {
123 fr.attempts = attempts;
124 fr.converge = rounds;
125 return CommandResult::carried("run", &res)
126 .with_data(CommandData::Feature(Box::new(fr)));
127 }
128 if let Some(p) = &options.progress {
129 p.event(&progress::Event::Notice {
130 text: format!("converge: assessing {} against its spec", fr.feature),
131 });
132 }
133 let c = convergence::converge_with_options(
134 path,
135 feature,
136 state,
137 Arc::clone(provider),
138 secret,
139 options,
140 );
141 if let Some(p) = &options.progress {
142 p.event(&progress::Event::Notice {
143 text: format!("converge: {}", c.message()),
144 });
145 }
146 let outcome = match c.data() {
147 Some(CommandData::Converge(r)) => {
148 let outcome = r.outcome.clone();
149 rounds.push((**r).clone());
150 outcome
151 }
152 _ => String::new(),
153 };
154 if outcome != "tasks_appended" {
155 fr.attempts = attempts;
156 fr.converge = rounds;
157 fr.stop = format!("converge: {}", c.message());
158 return CommandResult::new("run", c.status(), &fr.stop.clone())
159 .with_data(CommandData::Feature(Box::new(fr)));
160 }
161 }
162}
163
164pub fn run_feature_with(
166 path: &Path,
167 feature: Option<&str>,
168 state: &Path,
169 provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
170 secret: Option<&str>,
171 options: &RunOptions,
172) -> CommandResult {
173 if options.in_place && options.session.is_none() {
175 return in_session("run", path, feature, state, options, |o| {
176 run_feature_with(path, feature, state, provider, secret, o)
177 });
178 }
179 if let Some(slug) = bug_spec(path, feature) {
181 return crate::bug::run_with(
182 path,
183 Some(&slug),
184 None,
185 state,
186 Arc::clone(provider),
187 secret,
188 options,
189 );
190 }
191 let max_tasks = options.max_tasks;
192 use speccoder_core::FeatureReport;
193 use speccoder_core::snapshot::TaskState;
194
195 let mut attempts = Vec::new();
196 let mut feature_name = feature.map(str::to_owned);
197 let hook_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
198 let parallel_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
199 let full_tier: std::cell::RefCell<Option<speccoder_core::FullTier>> =
200 std::cell::RefCell::new(None);
201 let finish = |name: Option<String>,
202 attempts: Vec<speccoder_core::RunReport>,
203 status: CommandStatus,
204 stop: String| {
205 let autopilot_denied: Vec<speccoder_core::oracle::AutopilotDenial> =
207 if options.mode == speccoder_core::oracle::RunMode::Autopilot {
208 attempts
209 .iter()
210 .flat_map(|a| crate::oracle::autopilot_denials(&a.task, &a.tool_calls))
211 .collect()
212 } else {
213 Vec::new()
214 };
215 if let Some(n) = name.as_deref() {
216 crate::oracle::record_denials(
217 &worktree::project_state_dir(state, path),
218 n,
219 &autopilot_denied,
220 );
221 }
222 let stop = if autopilot_denied.is_empty() {
223 stop
224 } else {
225 format!(
226 "{stop} — autopilot refused {} call(s) instead of asking; they are listed for you (speccoder inbox)",
227 autopilot_denied.len()
228 )
229 };
230 CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
231 FeatureReport {
232 feature: name.unwrap_or_default(),
233 attempts,
234 stop,
235 converge: Vec::new(),
236 hooks: hook_lines.borrow().clone(),
237 parallel: parallel_lines.borrow().clone(),
238 full_tier: full_tier.borrow().clone(),
239 mode: options.mode,
240 autopilot_denied,
241 },
242 )))
243 };
244 let mut hinted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
246 let mut passed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
249 let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
251 let mut parallel_off = false;
252 let mut budget = max_tasks.unwrap_or(usize::MAX);
253 if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
255 return *stop;
256 }
257 let cap = match cap_of(path, options) {
259 Ok(c) => c,
260 Err(stop) => return *stop,
261 };
262 let mut told_uncounted = false;
264 match hosting::run_hooks(
266 "before_implement",
267 "",
268 path,
269 state,
270 provider,
271 secret,
272 options,
273 ) {
274 Ok(lines) => hook_lines.borrow_mut().extend(lines),
275 Err(stop) => {
276 return finish(
277 feature_name,
278 attempts,
279 stop.status(),
280 stop.message().to_owned(),
281 );
282 }
283 }
284 let mut planned = false;
285 while budget > 0 {
286 if options
287 .cancel
288 .as_ref()
289 .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
290 {
291 return finish(
292 feature_name,
293 attempts,
294 CommandStatus::NeedsHuman,
295 "cancelled by the person before the next task".to_owned(),
296 );
297 }
298 let wt = workspace(path, feature_name.as_deref(), state, options);
300 let Some(CommandData::Worktree(w)) = wt.data() else {
301 return CommandResult::carried("run", &wt);
302 };
303 if wt.status() != CommandStatus::Ok {
304 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
305 }
306 feature_name = Some(w.feature.clone());
307 let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
308 let format = crate::format::at(&project);
309 let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
310 return CommandResult::new(
311 "run",
312 CommandStatus::NotAProject,
313 format.messages().no_project_in_worktree,
314 );
315 };
316 let schedule = snap
317 .feature
318 .as_ref()
319 .and_then(|f| f.schedule.clone())
320 .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
321 tasks: Vec::new(),
322 totals: speccoder_core::snapshot::ScheduleTotals::default(),
323 });
324 if !planned {
325 planned = true;
326 if let Some(p) = &options.progress {
327 let skipped = controls::skipped(&project, &w.feature);
328 let titles: std::collections::HashMap<&str, &str> = snap
329 .feature
330 .as_ref()
331 .and_then(|f| f.tasks.as_ref())
332 .map(|t| {
333 t.tasks
334 .iter()
335 .map(|x| (x.id.as_str(), x.title.as_str()))
336 .collect()
337 })
338 .unwrap_or_default();
339 p.event(&crate::progress::Event::RunStarted {
340 feature: w.feature.clone(),
341 tasks: schedule
342 .tasks
343 .iter()
344 .map(|t| crate::progress::PlannedTask {
345 id: t.id.clone(),
346 title: titles
347 .get(t.id.as_str())
348 .copied()
349 .unwrap_or_default()
350 .to_owned(),
351 state: if skipped.contains_key(&t.id) {
352 "skipped".to_owned()
353 } else {
354 match t.state {
355 TaskState::Done => "done",
356 TaskState::Ready => "ready",
357 _ => "blocked",
358 }
359 .to_owned()
360 },
361 })
362 .collect(),
363 });
364 let open = schedule
366 .tasks
367 .iter()
368 .filter(|t| t.state != TaskState::Done && !skipped.contains_key(&t.id))
369 .count();
370 if let Some(line) = controls::spend_line(path, open, cap.map(|c| c.cap)) {
371 p.event(&crate::progress::Event::Notice { text: line });
372 }
373 if let Some(l) = crate::status::lanes(path, Some(&w.feature)) {
375 p.event(&crate::progress::Event::Notice { text: l.line() });
376 }
377 let warnings = crate::plan_lint::lints(format, &snap, &project).len();
380 if warnings > 0 {
381 p.event(&crate::progress::Event::Notice {
382 text: format!(
383 "plan: {warnings} warning{} — speccoder doctor --feature {}",
384 if warnings == 1 { "" } else { "s" },
385 w.feature
386 ),
387 });
388 }
389 }
390 }
391 if let Some(stop) = snap.feature.as_ref().and_then(|f| no_tasks(format, f)) {
394 return finish(
395 feature_name,
396 attempts,
397 CommandStatus::TaskNotReady,
398 stop.message,
399 )
400 .with_code("NO-TASKS")
401 .with_fix(stop.fix)
402 .with_next(stop.next);
403 }
404 let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
406 let parsed: Vec<&speccoder_core::snapshot::Task> = snap
410 .feature
411 .as_ref()
412 .and_then(|f| f.tasks.as_ref())
413 .map(|t| t.tasks.iter().collect())
414 .unwrap_or_default();
415 let saved = crate::attempts::list(&project, &w.feature);
416 let stopped = |id: &str| {
417 saved
418 .iter()
419 .find(|a| a.task == id && !controls::is_set_aside(a))
420 .filter(|a| a.outcome == "blocked")
421 .map(|a| a.reason.clone())
422 };
423 let yours = |id: &str| {
424 !hinted.contains(id)
425 && (parsed.iter().any(|t| t.id == id && t.yours) || stopped(id).is_some())
426 };
427 let Some(next) = schedule.tasks.iter().find(|t| {
428 t.state == TaskState::Ready && !skipped.contains_key(&t.id) && !passed.contains(&t.id)
429 }) else {
430 let open: Vec<String> = schedule
431 .tasks
432 .iter()
433 .filter(|t| t.state != TaskState::Done)
434 .map(|t| {
435 if skipped.contains_key(&t.id) {
436 format!("{} (skipped)", t.id)
437 } else {
438 t.id.clone()
439 }
440 })
441 .collect();
442 return if open.is_empty() {
443 if attempts
446 .iter()
447 .any(|a: &speccoder_core::RunReport| a.ticked)
448 {
449 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
450 let tier = full::run_once(&project, &wt_root);
451 full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
452 let failed = (!tier.passed).then(|| tier.failed.join(", "));
453 *full_tier.borrow_mut() = Some(tier);
454 if let Some(failed) = failed {
455 return finish(
456 feature_name,
457 attempts,
458 CommandStatus::VerificationFailed,
459 format!(
460 "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
461 ),
462 );
463 }
464 }
465 if let (Some(name), Ok(opened)) =
467 (feature_name.as_deref(), crate::format::open(path, feature))
468 {
469 let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
470 let plane = crate::worktree::project_state_dir(
471 state,
472 std::path::Path::new(&opened.snapshot.root),
473 );
474 if let Err(e) =
475 crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
476 {
477 hook_lines
478 .borrow_mut()
479 .push(format!("held-out sets not published: {e}"));
480 }
481 }
482 match hosting::run_hooks(
484 "after_implement",
485 "",
486 path,
487 state,
488 provider,
489 secret,
490 options,
491 ) {
492 Ok(lines) => hook_lines.borrow_mut().extend(lines),
493 Err(stop) => {
494 return finish(
495 feature_name,
496 attempts,
497 stop.status(),
498 stop.message().to_owned(),
499 );
500 }
501 }
502 finish(
503 feature_name,
504 attempts,
505 CommandStatus::Ok,
506 "feature done: every task is ticked".to_owned(),
507 )
508 } else if let Some(first) = passed.iter().next() {
509 let fix = speccoder_core::codes::lookup("TASK-YOURS")
511 .map(|e| {
512 speccoder_core::codes::fixes(e, "run", Some(first), feature_name.as_deref())
513 })
514 .unwrap_or_default();
515 let mine: Vec<String> = passed.iter().cloned().collect();
516 let message = match mine.as_slice() {
517 [one] => format!(
518 "{one} is yours — {}: a step only a person can take",
519 parsed
520 .iter()
521 .find(|t| t.id == *one)
522 .map(|t| t.title.as_str())
523 .unwrap_or_default()
524 ),
525 many => format!(
526 "{} are yours: steps only a person can take",
527 many.join(", ")
528 ),
529 };
530 finish(feature_name, attempts, CommandStatus::NeedsHuman, message)
531 .with_code("TASK-YOURS")
532 .with_fix(fix)
533 } else {
534 finish(
535 feature_name,
536 attempts,
537 CommandStatus::TaskNotReady,
538 format!("no task can run; open: {}", open.join(", ")),
539 )
540 };
541 };
542 let title_of = |id: &str| {
543 parsed
544 .iter()
545 .find(|t| t.id == id)
546 .map(|t| t.title.clone())
547 .unwrap_or_default()
548 };
549 if yours(&next.id) {
550 let id = next.id.clone();
551 let stop = crate::approvals::StopRequest {
552 task: format!("{}/{id}", w.feature),
553 title: title_of(&id),
554 claim: stopped(&id),
555 dependents: speccoder_core::graph::dependents(&schedule, &id),
556 };
557 if let Some(failed) = settle_stop(
558 path,
559 feature_name.as_deref(),
560 state,
561 options,
562 &stop,
563 &mut hinted,
564 &mut passed,
565 ) {
566 return finish(
567 feature_name,
568 attempts,
569 failed.status(),
570 format!("{id}: {}", failed.message()),
571 )
572 .caused_by(&failed);
573 }
574 continue;
575 }
576 if let Some(c) = cap
579 && let Some(stop) = capped(&c, &attempts, &schedule, &skipped, &w.feature)
580 {
581 return finish(feature_name, attempts, CommandStatus::NeedsHuman, stop.0)
582 .with_code("RUN-CAP")
583 .with_fix(vec![stop.1]);
584 }
585 let left = cap.map(|c| c.cap.less(spent(&c, &attempts)));
586 let parallel_ids: std::collections::BTreeSet<String> = snap
588 .feature
589 .as_ref()
590 .and_then(|f| f.tasks.as_ref())
591 .map(|t| {
592 t.tasks
593 .iter()
594 .filter(|t| t.parallel)
595 .map(|t| t.id.clone())
596 .collect()
597 })
598 .unwrap_or_default();
599 let group: Vec<String> = schedule
600 .tasks
601 .iter()
602 .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
603 .filter(|t| {
604 parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id) && !yours(&t.id)
605 })
606 .filter(|t| {
608 !super::story::oracle_pending(
609 std::path::Path::new(w.path.as_deref().unwrap_or_default()),
610 &crate::worktree::project_state_dir(state, path),
611 format,
612 &snap,
613 &t.id,
614 )
615 })
616 .map(|t| t.id.clone())
617 .take(options.parallel.min(budget))
618 .collect();
619 if !parallel_off && group.len() >= 2 {
620 budget = budget.saturating_sub(group.len());
621 let feature = feature_name.clone().unwrap_or_default();
622 let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
623 let shared = RunOptions {
624 left: left.map(|l| l.less(l.amount() - l.amount() / group.len() as u64)),
625 ..options.clone()
626 };
627 let lanes = run_lanes(path, &feature, &group, state, provider, secret, &shared);
628 let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
629 attempts.extend(outcome.reports);
630 parallel_lines.borrow_mut().extend(outcome.lines);
631 one_at_a_time.extend(outcome.one_at_a_time);
632 parallel_off |= outcome.reverted;
633 if let Some((task, res)) = outcome.stop {
634 let claim = attempts
636 .iter()
637 .rev()
638 .find(|a| a.task == task && a.attempt_status == "blocked")
639 .map(|a| a.reason.clone().unwrap_or_default());
640 let Some(claim) = claim else {
641 return finish(
642 feature_name,
643 attempts,
644 res.status(),
645 format!("{task}: {}", res.message()),
646 )
647 .caused_by(&res);
648 };
649 let stop = crate::approvals::StopRequest {
650 task: format!("{}/{task}", w.feature),
651 title: title_of(&task),
652 claim: Some(claim),
653 dependents: speccoder_core::graph::dependents(&schedule, &task),
654 };
655 if let Some(failed) = settle_stop(
656 path,
657 feature_name.as_deref(),
658 state,
659 options,
660 &stop,
661 &mut hinted,
662 &mut passed,
663 ) {
664 return finish(
665 feature_name,
666 attempts,
667 failed.status(),
668 format!("{task}: {}", failed.message()),
669 )
670 .caused_by(&failed);
671 }
672 }
673 continue;
674 }
675 budget -= 1;
676 let task = next.id.clone();
677 let res = run_task(
678 path,
679 feature_name.as_deref(),
680 &task,
681 state,
682 std::sync::Arc::clone(provider),
683 secret,
684 &RunOptions {
685 left,
686 ..options.clone()
687 },
688 );
689 if let Some(CommandData::Run(r)) = res.data() {
690 if cap.is_some()
691 && !told_uncounted
692 && !r.run_id.is_empty()
693 && super::commit::all_usage(r).is_none()
694 && let Some(p) = &options.progress
695 {
696 told_uncounted = true;
697 p.event(&crate::progress::Event::Notice {
698 text: "this run's agent reports no usage: the cap cannot count its attempts"
699 .to_owned(),
700 });
701 }
702 attempts.push((**r).clone());
703 }
704 if let Some(c) = cap
707 && res.status() != CommandStatus::Ok
708 && let Some(stop) = capped(&c, &attempts, &schedule, &skipped, &w.feature)
709 {
710 return finish(feature_name, attempts, CommandStatus::NeedsHuman, stop.0)
711 .with_code("RUN-CAP")
712 .with_fix(vec![stop.1]);
713 }
714 let claim = match res.data() {
717 Some(CommandData::Run(r)) if r.attempt_status == "blocked" => {
718 Some(r.reason.clone().unwrap_or_default())
719 }
720 _ => None,
721 };
722 if let Some(claim) = claim.filter(|_| res.status() != CommandStatus::Ok) {
723 let stop = crate::approvals::StopRequest {
724 task: format!("{}/{task}", w.feature),
725 title: title_of(&task),
726 claim: Some(claim),
727 dependents: speccoder_core::graph::dependents(&schedule, &task),
728 };
729 if let Some(failed) = settle_stop(
730 path,
731 feature_name.as_deref(),
732 state,
733 options,
734 &stop,
735 &mut hinted,
736 &mut passed,
737 ) {
738 return finish(
739 feature_name,
740 attempts,
741 failed.status(),
742 format!("{task}: {}", failed.message()),
743 )
744 .caused_by(&failed);
745 }
746 continue;
747 }
748 if res.status() != CommandStatus::Ok {
749 return finish(
750 feature_name,
751 attempts,
752 res.status(),
753 format!("{task}: {}", res.message()),
754 )
755 .caused_by(&res);
756 }
757 }
758 finish(
759 feature_name,
760 attempts,
761 CommandStatus::Ok,
762 "stopped at --max-tasks".to_owned(),
763 )
764}
765
766fn cap_of(
770 path: &Path,
771 options: &RunOptions,
772) -> Result<Option<substrate::attempt::Left>, Box<CommandResult>> {
773 let text = crate::verify::find_config(path)
774 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
775 .unwrap_or_default();
776 let configured = substrate::run_config::parse(&text).ok().and_then(|c| c.cap);
777 let Some(cap) = options.cap.or(configured) else {
778 return Ok(None);
779 };
780 let prices = substrate::model_config::parse(&text)
781 .ok()
782 .flatten()
783 .and_then(|m| m.prices);
784 if matches!(cap, substrate::run_config::Cap::Usd(_)) && prices.is_none() {
785 let fix = speccoder_core::codes::lookup("RUN-CAP-UNPRICED")
786 .map(|e| speccoder_core::codes::fixes(e, "run", None, None))
787 .unwrap_or_default();
788 return Err(Box::new(
789 CommandResult::new(
790 "run",
791 CommandStatus::ConfigError,
792 &format!(
793 "a cap of {cap} needs the model's prices: set them in [model], or cap tokens"
794 ),
795 )
796 .with_code("RUN-CAP-UNPRICED")
797 .with_fix(fix),
798 ));
799 }
800 Ok(Some(substrate::attempt::Left { cap, prices }))
801}
802
803fn spent(cap: &substrate::attempt::Left, attempts: &[speccoder_core::RunReport]) -> u64 {
806 attempts
807 .iter()
808 .map(|a| {
809 cap.spent(&substrate::attempt::usage_of(
810 super::commit::all_usage(a).as_ref(),
811 ))
812 })
813 .sum()
814}
815
816fn capped(
819 cap: &substrate::attempt::Left,
820 attempts: &[speccoder_core::RunReport],
821 schedule: &speccoder_core::snapshot::Schedule,
822 skipped: &std::collections::BTreeMap<String, String>,
823 feature: &str,
824) -> Option<(String, String)> {
825 let used = spent(cap, attempts);
826 if used < cap.cap.amount() {
827 return None;
828 }
829 let open: Vec<&str> = schedule
830 .tasks
831 .iter()
832 .filter(|t| {
833 t.state != speccoder_core::snapshot::TaskState::Done && !skipped.contains_key(&t.id)
834 })
835 .map(|t| t.id.as_str())
836 .collect();
837 let used = cap.cap.with(used);
838 Some((
839 format!(
840 "the run spent its cap: {} of {}; {} left — a new run starts its cap over",
841 used.rounded(),
842 cap.cap,
843 if open.is_empty() {
844 "nothing".to_owned()
845 } else {
846 open.join(", ")
847 }
848 ),
849 format!(
850 "speccoder run --cap {} --feature {feature}",
851 cap.cap.doubled()
852 ),
853 ))
854}
855
856fn settle_stop(
860 path: &Path,
861 feature: Option<&str>,
862 state: &Path,
863 options: &RunOptions,
864 stop: &crate::approvals::StopRequest,
865 hinted: &mut std::collections::BTreeSet<String>,
866 passed: &mut std::collections::BTreeSet<String>,
867) -> Option<CommandResult> {
868 let id = stop
869 .task
870 .rsplit('/')
871 .next()
872 .unwrap_or(&stop.task)
873 .to_owned();
874 match ask_at_stop(path, feature, state, options, stop) {
875 Some((answer, done)) if done.status() == CommandStatus::Ok => {
876 if matches!(answer, crate::approvals::StopAnswer::Hint(_)) {
877 hinted.insert(id);
878 }
879 None
880 }
881 Some((_, failed)) => Some(failed),
882 None => {
883 passed.insert(id);
884 None
885 }
886 }
887}
888
889fn ask_at_stop(
894 path: &Path,
895 feature: Option<&str>,
896 state: &Path,
897 options: &RunOptions,
898 stop: &crate::approvals::StopRequest,
899) -> Option<(crate::approvals::StopAnswer, CommandResult)> {
900 let approver = options
901 .approver
902 .as_ref()
903 .filter(|_| options.lane.is_none())?;
904 let answer = approver.at_stop(stop)?;
905 let task = stop.task.rsplit('/').next().unwrap_or(&stop.task);
906 let done = crate::actions::answered_at_stop(
907 path,
908 feature,
909 task,
910 &answer,
911 (approver.via(), options),
912 state,
913 );
914 Some((answer, done))
915}
916
917#[derive(Debug, Clone)]
919pub(crate) struct NoTasks {
920 pub message: String,
922 pub fix: Vec<String>,
924 pub next: Vec<speccoder_core::next::NextStep>,
926}
927
928pub(crate) fn no_tasks(
931 format: &dyn crate::format::Adapter,
932 feature: &speccoder_core::snapshot::FeatureSnapshot,
933) -> Option<NoTasks> {
934 if feature.tasks.as_ref().is_some_and(|t| !t.tasks.is_empty()) {
935 return None;
936 }
937 let messages = format.messages();
938 let skills: Vec<String> = (format.planning().toward_tasks)(feature.artifacts.plan)
939 .iter()
940 .map(|s| (*s).to_owned())
941 .collect();
942 let name = &feature.feature.name;
943 Some(NoTasks {
944 message: format!(
945 "no tasks yet: {name} {}; {}",
946 if feature.tasks.is_some() {
947 format!("has a {} without a task", messages.tasks_file)
948 } else {
949 format!("has no {}", messages.tasks_file)
950 },
951 messages.make_tasks
952 ),
953 fix: skills
954 .iter()
955 .map(|s| format!("speccoder spec {s}"))
956 .collect(),
957 next: speccoder_core::next::next(
958 &speccoder_core::next::Outcome::Planning { skills },
959 Some(name),
960 ),
961 })
962}