Skip to main content

speccoder_runtime/pipeline/
feature.rs

1//! `speccoder run` over a whole feature, and `converge`.
2
3use 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/// `speccoder run` without `--task`: attempts ready tasks in document order
12/// (read from the worktree) until the feature is done or an attempt does
13/// not pass. The provider comes from `[model]` and its key variable.
14#[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/// `speccoder converge`: assess the finished feature and append what remains
31///. The provider comes from `[model]` and its key variable.
32#[must_use]
33pub fn converge(path: &Path, feature: Option<&str>) -> CommandResult {
34    converge_in(path, feature, false)
35}
36
37/// [`converge`], in the person's checkout when `in_place`.
38#[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
56/// Opens the configured model driver (key from the environment only) and
57/// hands it to `f` with the state directory.
58pub(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
86/// `run --converge`: work the feature through, converge, and work through
87/// what convergence appended — until it converges, stops making progress,
88/// reaches `[converge] max_rounds`, or an attempt needs a person.
89pub 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    // In place: one session over every round.
98    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    loop {
106        let res = run_feature_with(path, feature, state, provider, secret, options);
107        let Some(CommandData::Feature(fr)) = res.data() else {
108            return res;
109        };
110        let mut fr = (**fr).clone();
111        attempts.append(&mut fr.attempts);
112        let finished = res.status() == CommandStatus::Ok && fr.stop.starts_with("feature done");
113        if !finished {
114            fr.attempts = attempts;
115            fr.converge = rounds;
116            return CommandResult::carried("run", &res)
117                .with_data(CommandData::Feature(Box::new(fr)));
118        }
119        if let Some(p) = &options.progress {
120            p.event(&progress::Event::Notice {
121                text: format!("converge: assessing {} against its spec", fr.feature),
122            });
123        }
124        let c = convergence::converge_with_options(
125            path,
126            feature,
127            state,
128            Arc::clone(provider),
129            secret,
130            options,
131        );
132        if let Some(p) = &options.progress {
133            p.event(&progress::Event::Notice {
134                text: format!("converge: {}", c.message()),
135            });
136        }
137        let outcome = match c.data() {
138            Some(CommandData::Converge(r)) => {
139                let outcome = r.outcome.clone();
140                rounds.push((**r).clone());
141                outcome
142            }
143            _ => String::new(),
144        };
145        if outcome != "tasks_appended" {
146            fr.attempts = attempts;
147            fr.converge = rounds;
148            fr.stop = format!("converge: {}", c.message());
149            return CommandResult::new("run", c.status(), &fr.stop.clone())
150                .with_data(CommandData::Feature(Box::new(fr)));
151        }
152    }
153}
154
155/// [`run_feature`] with an explicit state directory and model driver.
156pub fn run_feature_with(
157    path: &Path,
158    feature: Option<&str>,
159    state: &Path,
160    provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
161    secret: Option<&str>,
162    options: &RunOptions,
163) -> CommandResult {
164    // In place: one session around the whole feature.
165    if options.in_place && options.session.is_none() {
166        return in_session("run", path, feature, state, options, |o| {
167            run_feature_with(path, feature, state, provider, secret, o)
168        });
169    }
170    let max_tasks = options.max_tasks;
171    use speccoder_core::FeatureReport;
172    use speccoder_core::snapshot::TaskState;
173
174    let mut attempts = Vec::new();
175    let mut feature_name = feature.map(str::to_owned);
176    let hook_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
177    let parallel_lines: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
178    let full_tier: std::cell::RefCell<Option<speccoder_core::FullTier>> =
179        std::cell::RefCell::new(None);
180    let finish = |name: Option<String>,
181                  attempts: Vec<speccoder_core::RunReport>,
182                  status: CommandStatus,
183                  stop: String| {
184        // An autopilot run lists every call it refused instead of asking.
185        let autopilot_denied: Vec<speccoder_core::oracle::AutopilotDenial> =
186            if options.mode == speccoder_core::oracle::RunMode::Autopilot {
187                attempts
188                    .iter()
189                    .flat_map(|a| crate::oracle::autopilot_denials(&a.task, &a.tool_calls))
190                    .collect()
191            } else {
192                Vec::new()
193            };
194        if let Some(n) = name.as_deref() {
195            crate::oracle::record_denials(
196                &worktree::project_state_dir(state, path),
197                n,
198                &autopilot_denied,
199            );
200        }
201        let stop = if autopilot_denied.is_empty() {
202            stop
203        } else {
204            format!(
205                "{stop} — autopilot refused {} call(s) instead of asking; they are listed for you (speccoder inbox)",
206                autopilot_denied.len()
207            )
208        };
209        CommandResult::new("run", status, &stop).with_data(CommandData::Feature(Box::new(
210            FeatureReport {
211                feature: name.unwrap_or_default(),
212                attempts,
213                stop,
214                converge: Vec::new(),
215                hooks: hook_lines.borrow().clone(),
216                parallel: parallel_lines.borrow().clone(),
217                full_tier: full_tier.borrow().clone(),
218                mode: options.mode,
219                autopilot_denied,
220            },
221        )))
222    };
223    // `[YOU]` tasks the person gave a hint for: attempted after all.
224    let mut hinted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
225    // Tasks that are yours and nobody answered for: passed by like a
226    // skipped one, listed at the end with their three actions.
227    let mut passed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
228    // Tasks that must run on their own after a parallel group did not merge.
229    let mut one_at_a_time: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
230    let mut parallel_off = false;
231    let mut budget = max_tasks.unwrap_or(usize::MAX);
232    // Unfinished checklists stop the run before anything runs.
233    if let Err(stop) = checklist_gate(path, feature, options.ignore_checklists) {
234        return *stop;
235    }
236    // The format's `before_implement` hooks.
237    match hosting::run_hooks(
238        "before_implement",
239        "",
240        path,
241        state,
242        provider,
243        secret,
244        options,
245    ) {
246        Ok(lines) => hook_lines.borrow_mut().extend(lines),
247        Err(stop) => {
248            return finish(
249                feature_name,
250                attempts,
251                stop.status(),
252                stop.message().to_owned(),
253            );
254        }
255    }
256    let mut planned = false;
257    while budget > 0 {
258        if options
259            .cancel
260            .as_ref()
261            .is_some_and(|c| c.load(std::sync::atomic::Ordering::SeqCst))
262        {
263            return finish(
264                feature_name,
265                attempts,
266                CommandStatus::NeedsHuman,
267                "cancelled by the person before the next task".to_owned(),
268            );
269        }
270        // The next ready task, read from the worktree.
271        let wt = workspace(path, feature_name.as_deref(), state, options);
272        let Some(CommandData::Worktree(w)) = wt.data() else {
273            return CommandResult::carried("run", &wt);
274        };
275        if wt.status() != CommandStatus::Ok {
276            return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
277        }
278        feature_name = Some(w.feature.clone());
279        let project = std::path::PathBuf::from(w.project_dir.clone().unwrap_or_default());
280        let format = crate::format::at(&project);
281        let Some(snap) = format.inspect(&project, Some(&w.feature)) else {
282            return CommandResult::new(
283                "run",
284                CommandStatus::NotAProject,
285                format.messages().no_project_in_worktree,
286            );
287        };
288        let schedule = snap
289            .feature
290            .as_ref()
291            .and_then(|f| f.schedule.clone())
292            .unwrap_or_else(|| speccoder_core::snapshot::Schedule {
293                tasks: Vec::new(),
294                totals: speccoder_core::snapshot::ScheduleTotals::default(),
295            });
296        if !planned {
297            planned = true;
298            if let Some(p) = &options.progress {
299                let skipped = controls::skipped(&project, &w.feature);
300                let titles: std::collections::HashMap<&str, &str> = snap
301                    .feature
302                    .as_ref()
303                    .and_then(|f| f.tasks.as_ref())
304                    .map(|t| {
305                        t.tasks
306                            .iter()
307                            .map(|x| (x.id.as_str(), x.title.as_str()))
308                            .collect()
309                    })
310                    .unwrap_or_default();
311                p.event(&crate::progress::Event::RunStarted {
312                    feature: w.feature.clone(),
313                    tasks: schedule
314                        .tasks
315                        .iter()
316                        .map(|t| crate::progress::PlannedTask {
317                            id: t.id.clone(),
318                            title: titles
319                                .get(t.id.as_str())
320                                .copied()
321                                .unwrap_or_default()
322                                .to_owned(),
323                            state: if skipped.contains_key(&t.id) {
324                                "skipped".to_owned()
325                            } else {
326                                match t.state {
327                                    TaskState::Done => "done",
328                                    TaskState::Ready => "ready",
329                                    _ => "blocked",
330                                }
331                                .to_owned()
332                            },
333                        })
334                        .collect(),
335                });
336                // Who does what, said once: information, not a warning.
337                if let Some(l) = crate::status::lanes(path, Some(&w.feature)) {
338                    p.event(&crate::progress::Event::Notice { text: l.line() });
339                }
340                // The plan checks, said once per run start; they never
341                // stop, delay or re-order it.
342                let warnings = crate::plan_lint::lints(format, &snap, &project).len();
343                if warnings > 0 {
344                    p.event(&crate::progress::Event::Notice {
345                        text: format!(
346                            "plan: {warnings} warning{} — speccoder doctor --feature {}",
347                            if warnings == 1 { "" } else { "s" },
348                            w.feature
349                        ),
350                    });
351                }
352            }
353        }
354        // No task to work: the feature is not done, its tasks are not
355        // written yet (no task list, or one without a task).
356        if let Some(stop) = snap.feature.as_ref().and_then(|f| no_tasks(format, f)) {
357            return finish(
358                feature_name,
359                attempts,
360                CommandStatus::TaskNotReady,
361                stop.message,
362            )
363            .with_code("NO-TASKS")
364            .with_fix(stop.fix)
365            .with_next(stop.next);
366        }
367        // Skipped tasks are passed by; what depends on them stays blocked.
368        let skipped = controls::skipped(&project, feature_name.as_deref().unwrap_or_default());
369        // A task that is yours — marked `[YOU]`, or its last attempt stopped
370        // `BLOCKED` — is not attempted: the person is asked, or it is passed
371        // by until they answer (`run --task` and `task hint` attempt it).
372        let parsed: Vec<&speccoder_core::snapshot::Task> = snap
373            .feature
374            .as_ref()
375            .and_then(|f| f.tasks.as_ref())
376            .map(|t| t.tasks.iter().collect())
377            .unwrap_or_default();
378        let saved = crate::attempts::list(&project, &w.feature);
379        let stopped = |id: &str| {
380            saved
381                .iter()
382                .find(|a| a.task == id && !controls::is_set_aside(a))
383                .filter(|a| a.outcome == "blocked")
384                .map(|a| a.reason.clone())
385        };
386        let yours = |id: &str| {
387            !hinted.contains(id)
388                && (parsed.iter().any(|t| t.id == id && t.yours) || stopped(id).is_some())
389        };
390        let Some(next) = schedule.tasks.iter().find(|t| {
391            t.state == TaskState::Ready && !skipped.contains_key(&t.id) && !passed.contains(&t.id)
392        }) else {
393            let open: Vec<String> = schedule
394                .tasks
395                .iter()
396                .filter(|t| t.state != TaskState::Done)
397                .map(|t| {
398                    if skipped.contains_key(&t.id) {
399                        format!("{} (skipped)", t.id)
400                    } else {
401                        t.id.clone()
402                    }
403                })
404                .collect();
405            return if open.is_empty() {
406                // The `full` tier, once, when this run ticked the last task:
407                // a failure is reported and stops `pr`; the ticks stand.
408                if attempts
409                    .iter()
410                    .any(|a: &speccoder_core::RunReport| a.ticked)
411                {
412                    let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
413                    let tier = full::run_once(&project, &wt_root);
414                    full::record(&wt_root, feature_name.as_deref().unwrap_or_default(), &tier);
415                    let failed = (!tier.passed).then(|| tier.failed.join(", "));
416                    *full_tier.borrow_mut() = Some(tier);
417                    if let Some(failed) = failed {
418                        return finish(
419                            feature_name,
420                            attempts,
421                            CommandStatus::VerificationFailed,
422                            format!(
423                                "every task is ticked, but the full-tier checks failed: {failed}; fix them, or `speccoder pr --force`"
424                            ),
425                        );
426                    }
427                }
428                // The held-out sets, now that no later task can read them.
429                if let (Some(name), Ok(opened)) =
430                    (feature_name.as_deref(), crate::format::open(path, feature))
431                {
432                    let wt_root = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
433                    let plane = crate::worktree::project_state_dir(
434                        state,
435                        std::path::Path::new(&opened.snapshot.root),
436                    );
437                    if let Err(e) =
438                        crate::held_out::publish(&wt_root, &plane, name, &plane.join("tmp"))
439                    {
440                        hook_lines
441                            .borrow_mut()
442                            .push(format!("held-out sets not published: {e}"));
443                    }
444                }
445                // The format's `after_implement` hooks.
446                match hosting::run_hooks(
447                    "after_implement",
448                    "",
449                    path,
450                    state,
451                    provider,
452                    secret,
453                    options,
454                ) {
455                    Ok(lines) => hook_lines.borrow_mut().extend(lines),
456                    Err(stop) => {
457                        return finish(
458                            feature_name,
459                            attempts,
460                            stop.status(),
461                            stop.message().to_owned(),
462                        );
463                    }
464                }
465                finish(
466                    feature_name,
467                    attempts,
468                    CommandStatus::Ok,
469                    "feature done: every task is ticked".to_owned(),
470                )
471            } else if let Some(first) = passed.iter().next() {
472                // What waits is the person's: named, with its three actions.
473                let fix = speccoder_core::codes::lookup("TASK-YOURS")
474                    .map(|e| {
475                        speccoder_core::codes::fixes(e, "run", Some(first), feature_name.as_deref())
476                    })
477                    .unwrap_or_default();
478                let mine: Vec<String> = passed.iter().cloned().collect();
479                let message = match mine.as_slice() {
480                    [one] => format!(
481                        "{one} is yours — {}: a step only a person can take",
482                        parsed
483                            .iter()
484                            .find(|t| t.id == *one)
485                            .map(|t| t.title.as_str())
486                            .unwrap_or_default()
487                    ),
488                    many => format!(
489                        "{} are yours: steps only a person can take",
490                        many.join(", ")
491                    ),
492                };
493                finish(feature_name, attempts, CommandStatus::NeedsHuman, message)
494                    .with_code("TASK-YOURS")
495                    .with_fix(fix)
496            } else {
497                finish(
498                    feature_name,
499                    attempts,
500                    CommandStatus::TaskNotReady,
501                    format!("no task can run; open: {}", open.join(", ")),
502                )
503            };
504        };
505        let title_of = |id: &str| {
506            parsed
507                .iter()
508                .find(|t| t.id == id)
509                .map(|t| t.title.clone())
510                .unwrap_or_default()
511        };
512        if yours(&next.id) {
513            let id = next.id.clone();
514            let stop = crate::approvals::StopRequest {
515                task: format!("{}/{id}", w.feature),
516                title: title_of(&id),
517                claim: stopped(&id),
518                dependents: speccoder_core::graph::dependents(&schedule, &id),
519            };
520            if let Some(failed) = settle_stop(
521                path,
522                feature_name.as_deref(),
523                state,
524                options,
525                &stop,
526                &mut hinted,
527                &mut passed,
528            ) {
529                return finish(
530                    feature_name,
531                    attempts,
532                    failed.status(),
533                    format!("{id}: {}", failed.message()),
534                )
535                .caused_by(&failed);
536            }
537            continue;
538        }
539        // ready [P] tasks run at once, each in its own lane.
540        let parallel_ids: std::collections::BTreeSet<String> = snap
541            .feature
542            .as_ref()
543            .and_then(|f| f.tasks.as_ref())
544            .map(|t| {
545                t.tasks
546                    .iter()
547                    .filter(|t| t.parallel)
548                    .map(|t| t.id.clone())
549                    .collect()
550            })
551            .unwrap_or_default();
552        let group: Vec<String> = schedule
553            .tasks
554            .iter()
555            .filter(|t| t.state == TaskState::Ready && !skipped.contains_key(&t.id))
556            .filter(|t| {
557                parallel_ids.contains(&t.id) && !one_at_a_time.contains(&t.id) && !yours(&t.id)
558            })
559            .map(|t| t.id.clone())
560            .take(options.parallel.min(budget))
561            .collect();
562        if !parallel_off && group.len() >= 2 {
563            budget = budget.saturating_sub(group.len());
564            let feature = feature_name.clone().unwrap_or_default();
565            let feature_wt = std::path::PathBuf::from(w.path.clone().unwrap_or_default());
566            let lanes = run_lanes(path, &feature, &group, state, provider, secret, options);
567            let outcome = merge_lanes(path, &feature_wt, &project, &feature, lanes);
568            attempts.extend(outcome.reports);
569            parallel_lines.borrow_mut().extend(outcome.lines);
570            one_at_a_time.extend(outcome.one_at_a_time);
571            parallel_off |= outcome.reverted;
572            if let Some((task, status, message)) = outcome.stop {
573                // An honest stop in a lane is asked about like any other.
574                let claim = attempts
575                    .iter()
576                    .rev()
577                    .find(|a| a.task == task && a.attempt_status == "blocked")
578                    .map(|a| a.reason.clone().unwrap_or_default());
579                let Some(claim) = claim else {
580                    return finish(feature_name, attempts, status, format!("{task}: {message}"));
581                };
582                let stop = crate::approvals::StopRequest {
583                    task: format!("{}/{task}", w.feature),
584                    title: title_of(&task),
585                    claim: Some(claim),
586                    dependents: speccoder_core::graph::dependents(&schedule, &task),
587                };
588                if let Some(failed) = settle_stop(
589                    path,
590                    feature_name.as_deref(),
591                    state,
592                    options,
593                    &stop,
594                    &mut hinted,
595                    &mut passed,
596                ) {
597                    return finish(
598                        feature_name,
599                        attempts,
600                        failed.status(),
601                        format!("{task}: {}", failed.message()),
602                    )
603                    .caused_by(&failed);
604                }
605            }
606            continue;
607        }
608        budget -= 1;
609        let task = next.id.clone();
610        let res = run_task(
611            path,
612            feature_name.as_deref(),
613            &task,
614            state,
615            std::sync::Arc::clone(provider),
616            secret,
617            options,
618        );
619        if let Some(CommandData::Run(r)) = res.data() {
620            attempts.push((**r).clone());
621        }
622        // An honest stop: the person is asked in place — I did it, a hint
623        // and a new attempt, or skip — or it is passed by; the run goes on.
624        let claim = match res.data() {
625            Some(CommandData::Run(r)) if r.attempt_status == "blocked" => {
626                Some(r.reason.clone().unwrap_or_default())
627            }
628            _ => None,
629        };
630        if let Some(claim) = claim.filter(|_| res.status() != CommandStatus::Ok) {
631            let stop = crate::approvals::StopRequest {
632                task: format!("{}/{task}", w.feature),
633                title: title_of(&task),
634                claim: Some(claim),
635                dependents: speccoder_core::graph::dependents(&schedule, &task),
636            };
637            if let Some(failed) = settle_stop(
638                path,
639                feature_name.as_deref(),
640                state,
641                options,
642                &stop,
643                &mut hinted,
644                &mut passed,
645            ) {
646                return finish(
647                    feature_name,
648                    attempts,
649                    failed.status(),
650                    format!("{task}: {}", failed.message()),
651                )
652                .caused_by(&failed);
653            }
654            continue;
655        }
656        if res.status() != CommandStatus::Ok {
657            return finish(
658                feature_name,
659                attempts,
660                res.status(),
661                format!("{task}: {}", res.message()),
662            )
663            .caused_by(&res);
664        }
665    }
666    finish(
667        feature_name,
668        attempts,
669        CommandStatus::Ok,
670        "stopped at --max-tasks".to_owned(),
671    )
672}
673
674/// A task that needs the person, settled: asked ([`ask_at_stop`]) — after
675/// a hint the run attempts it again — or, unanswered, passed by. `Some`:
676/// taking the answer failed, and the run ends with that.
677fn settle_stop(
678    path: &Path,
679    feature: Option<&str>,
680    state: &Path,
681    options: &RunOptions,
682    stop: &crate::approvals::StopRequest,
683    hinted: &mut std::collections::BTreeSet<String>,
684    passed: &mut std::collections::BTreeSet<String>,
685) -> Option<CommandResult> {
686    let id = stop
687        .task
688        .rsplit('/')
689        .next()
690        .unwrap_or(&stop.task)
691        .to_owned();
692    match ask_at_stop(path, feature, state, options, stop) {
693        Some((answer, done)) if done.status() == CommandStatus::Ok => {
694            if matches!(answer, crate::approvals::StopAnswer::Hint(_)) {
695                hinted.insert(id);
696            }
697            None
698        }
699        Some((_, failed)) => Some(failed),
700        None => {
701            passed.insert(id);
702            None
703        }
704    }
705}
706
707/// Asks the person at a task that needs them — at a terminal, never in a
708/// lane — and takes the answer — I did it, a hint, skip — through the one
709/// dispatcher ([`crate::actions::answered_at_stop`]); after a hint the run
710/// attempts the task again. `None`: nobody answered; the task waits in Needs You.
711fn ask_at_stop(
712    path: &Path,
713    feature: Option<&str>,
714    state: &Path,
715    options: &RunOptions,
716    stop: &crate::approvals::StopRequest,
717) -> Option<(crate::approvals::StopAnswer, CommandResult)> {
718    let approver = options
719        .approver
720        .as_ref()
721        .filter(|_| options.lane.is_none())?;
722    let answer = approver.at_stop(stop)?;
723    let task = stop.task.rsplit('/').next().unwrap_or(&stop.task);
724    let done = crate::actions::answered_at_stop(
725        path,
726        feature,
727        task,
728        &answer,
729        (approver.via(), options),
730        state,
731    );
732    Some((answer, done))
733}
734
735/// Why a unit has nothing to run: no task list, or one without a task.
736#[derive(Debug, Clone)]
737pub(crate) struct NoTasks {
738    /// `no tasks yet: …`, naming the format's file and how to write it.
739    pub message: String,
740    /// The planning skills that write the tasks, as commands.
741    pub fix: Vec<String>,
742    /// The same as next steps, then the run.
743    pub next: Vec<speccoder_core::next::NextStep>,
744}
745
746/// [`NoTasks`] for `feature`, unless it has a task: such a unit is never
747/// done (code `NO-TASKS`).
748pub(crate) fn no_tasks(
749    format: &dyn crate::format::Adapter,
750    feature: &speccoder_core::snapshot::FeatureSnapshot,
751) -> Option<NoTasks> {
752    if feature.tasks.as_ref().is_some_and(|t| !t.tasks.is_empty()) {
753        return None;
754    }
755    let messages = format.messages();
756    let skills: Vec<String> = (format.planning().toward_tasks)(feature.artifacts.plan)
757        .iter()
758        .map(|s| (*s).to_owned())
759        .collect();
760    let name = &feature.feature.name;
761    Some(NoTasks {
762        message: format!(
763            "no tasks yet: {name} {}; {}",
764            if feature.tasks.is_some() {
765                format!("has a {} without a task", messages.tasks_file)
766            } else {
767                format!("has no {}", messages.tasks_file)
768            },
769            messages.make_tasks
770        ),
771        fix: skills
772            .iter()
773            .map(|s| format!("speccoder spec {s}"))
774            .collect(),
775        next: speccoder_core::next::next(
776            &speccoder_core::next::Outcome::Planning { skills },
777            Some(name),
778        ),
779    })
780}