Skip to main content

speccoder_runtime/
actions.rs

1//! What a person can do now, and taking it. The list comes from the
2//! project as it stands: the report, what waits for a decision, the
3//! planning files not yet committed and the installed planning skills.
4//! Interfaces render the list; [`perform`] is the one place an action's
5//! [`Runnable`] becomes runtime calls, for every interface.
6//!
7//! # Adding a kind
8//!
9//! Every new person-initiated action — a model run or a plain write —
10//! lands in one change with all of:
11//!
12//! 1. a `Runnable` variant with typed fields (`speccoder_core::actions`);
13//! 2. its `ActionKind`, its `identity` arm and its subject rule (same file);
14//! 3. exactly one arm in [`perform_in`] (input rules, `needs(..)`
15//!    messages, the dispatch's `via` passed through);
16//! 4. its result payload: an existing `CommandData` variant, or a new one
17//!    with a unique name in `PAYLOADS`;
18//! 5. that payload's published schema (`SPECCODER_BLESS=1`);
19//! 6. its generated UI type (`crates/speccoder-web/ui/src/lib/schema.gen.ts`);
20//! 7. a decision for every interface in [`offered`] (no wildcard: the
21//!    build fails until each interface has one);
22//! 8. a golden for its arm (`tests/fixtures/perform/`);
23//! 9. if the web app shows it, its verb and icon in the UI's kind table
24//!    (`ui/src/lib/actions.ts`).
25//!
26//! No feature adds a web job kind, a web route that writes, a terminal
27//! prompt, an editor command or a dashboard key that maps a kind to runtime
28//! calls itself, nor a marker on an existing variant meaning another
29//! action: it adds a kind. Only the interfaces of [`Interface`] call
30//! [`perform`] — never a model tool, a hook, a gate or the engine — so
31//! nothing but a person's command approves, rejects, accepts, rules or
32//! ticks through it. `specs/089-action-contract/quickstart.md` walks
33//! through one such change.
34
35use std::path::Path;
36
37use speccoder_core::actions::{Action, ActionKind, Runnable, Via};
38use speccoder_core::{ApprovalsReport, CommandData, CommandResult, CommandStatus, StatusReport};
39
40use crate::RunOptions;
41
42pub(crate) fn action(
43    group: &str,
44    label: impl Into<String>,
45    why: impl Into<String>,
46    command: impl Into<String>,
47) -> Action {
48    Action::new(group, label, why, command)
49}
50
51/// What can be done now in the project at `path` (`feature` as for every
52/// command), with `selected` the task an interface has selected.
53#[must_use]
54pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
55    available_with(path, feature, selected, None)
56}
57
58/// [`available`] with the waiting calls already listed (an interface that
59/// lists them anyway): the journal is not opened again.
60#[must_use]
61pub fn available_with(
62    path: &Path,
63    feature: Option<&str>,
64    selected: Option<&str>,
65    listed: Option<&ApprovalsReport>,
66) -> Vec<Action> {
67    let report = crate::status::report(path, feature);
68    let report: Option<&StatusReport> = match report.data() {
69        Some(CommandData::Report(r)) => Some(r),
70        _ => None,
71    };
72    let approvals = listed.cloned().or_else(|| {
73        crate::worktree::state_dir().and_then(|state| {
74            match crate::approvals::list_with(path, feature, &state).data() {
75                Some(CommandData::Approvals(a)) => Some((**a).clone()),
76                _ => None,
77            }
78        })
79    });
80    let uncommitted = crate::hosting::uncommitted(path, feature);
81    let skills = match crate::hosting::list(path).data() {
82        Some(CommandData::Skills(s)) => s.skills.clone(),
83        _ => Vec::new(),
84    };
85    let format = crate::format::at(path);
86    let planning = format.planning();
87    let unit = format.messages().unit;
88    let features = crate::current::list(path, None);
89    let feature_count = match features.data() {
90        Some(CommandData::Features(f)) => f.features.len(),
91        _ => 0,
92    };
93    let short = |name: &str| {
94        name.strip_prefix(planning.prefix)
95            .unwrap_or(name)
96            .to_owned()
97    };
98    let skill = |group: &str, name: &str, why: &str| {
99        let creates = planning.creates.contains(&name);
100        let mut a = action(
101            group,
102            format!("spec {name}"),
103            why,
104            if creates {
105                format!("speccoder spec {name} \"…\"")
106            } else {
107                format!("speccoder spec {name}")
108            },
109        );
110        a = a
111            .with_run(Runnable::Skill {
112                name: name.to_owned(),
113                arguments: String::new(),
114            })
115            .with_role(planning.role_of(name));
116        if creates {
117            a.input = Some(format!("Describe the {unit}"));
118        }
119        a
120    };
121    let mut out = Vec::new();
122
123    // ── next: what moves the work forward, most urgent first ──────────
124    for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
125        let mut a = action(
126            "next",
127            format!("approve {}'s waiting call", p.task),
128            format!("{} — {}", p.covers, p.reason),
129            format!("speccoder task approve {}", p.task),
130        );
131        a = a.with_run(Runnable::Approve {
132            task: p.task.clone(),
133            call: p.id(),
134            digest: p.digest.clone(),
135        });
136        out.push(a);
137        let mut r = action(
138            "next",
139            format!("reject {}'s waiting call", p.task),
140            "the model is told your reason",
141            format!("speccoder task reject {} --reason \"…\"", p.task),
142        );
143        r = r.with_run(Runnable::Reject {
144            task: p.task.clone(),
145            call: p.id(),
146            digest: p.digest.clone(),
147        });
148        r.input = Some("Why (the model is told)".to_owned());
149        out.push(r);
150    }
151    let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
152    for name in &planning_next {
153        let why = match name.as_str() {
154            "plan" => "write the plan from the spec",
155            "tasks" => "break the plan into tasks",
156            _ => "the next planning step",
157        };
158        out.push(skill("next", name, why));
159    }
160    if !uncommitted.is_empty() {
161        let mut a = action(
162            "next",
163            format!("commit the planning files ({})", uncommitted.len()),
164            "speccoder runs from committed files",
165            format!(
166                "git add {0} && git commit -m \"spec: …\" -- {0}",
167                uncommitted.join(" ")
168            ),
169        );
170        a = a.with_run(Runnable::Commit {
171            paths: uncommitted.clone(),
172        });
173        out.push(a);
174    }
175    let ready = report.map_or(0, |r| r.totals.ready);
176    let run_blocked = (!uncommitted.is_empty())
177        .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
178    if ready > 0 && planning_next.is_empty() {
179        let mut a = action(
180            "next",
181            "run the feature",
182            format!("{ready} ready task(s), each judged on evidence"),
183            "speccoder run",
184        );
185        a = a.with_run(Runnable::Run { task: None });
186        a.blocked = run_blocked.clone();
187        out.push(a);
188    }
189    // The spec changed under ticks that cite it: their evidence is for the
190    // earlier wording. Convergence assesses the code against it as it is.
191    let stale: Vec<&str> = report
192        .map(|r| {
193            r.tasks
194                .iter()
195                .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
196                .map(|t| t.id.as_str())
197                .collect()
198        })
199        .unwrap_or_default();
200    if !stale.is_empty() && planning_next.is_empty() {
201        let mut a = action(
202            "next",
203            "re-check what the spec changed",
204            format!(
205                "{} was proven against an earlier wording; convergence assesses the code against the spec as it is now and adds tasks for what no longer holds",
206                stale.join(", ")
207            ),
208            "speccoder run --converge",
209        );
210        a = a.with_run(Runnable::Converge);
211        a.blocked = run_blocked.clone();
212        out.push(a);
213    }
214    if report.is_none()
215        && feature_count == 0
216        && format.detect(path).is_some()
217        && let Some(create) = planning.creates.first()
218    {
219        out.push(skill(
220            "next",
221            create,
222            &format!("write the first {unit}'s spec"),
223        ));
224    }
225
226    // ── plan: every hosted planning skill ─────────────────────────────
227    for s in skills.iter().filter(|s| s.refused.is_none()) {
228        let name = short(&s.name);
229        if planning_next.contains(&name) {
230            continue;
231        }
232        out.push(skill("plan", &name, &s.description));
233    }
234
235    // ── work ───────────────────────────────────────────────────────────
236    if let Some(t) =
237        selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
238    {
239        if t.state == "ready" {
240            let mut a = action(
241                "work",
242                format!("run {}", t.id),
243                t.title.clone(),
244                format!("speccoder run --task {}", t.id),
245            );
246            a = a.with_run(Runnable::Run {
247                task: Some(t.id.clone()),
248            });
249            a.blocked = run_blocked.clone();
250            out.push(a);
251        }
252        // ── task: the selected one ────────────────────────────────────
253        if !matches!(
254            t.state.as_str(),
255            "verified" | "regression_only" | "attested"
256        ) {
257            for (verb, why, prompt, run) in [
258                (
259                    "accept",
260                    "tick it on your word (attested, not verified)",
261                    "Why it is done",
262                    Runnable::Accept { task: t.id.clone() },
263                ),
264                (
265                    "skip",
266                    "runs pass it by; what depends on it stays blocked",
267                    "Why it is skipped",
268                    Runnable::Skip { task: t.id.clone() },
269                ),
270                (
271                    "nudge",
272                    "a note its next attempts read",
273                    "The note",
274                    Runnable::Nudge { task: t.id.clone() },
275                ),
276            ] {
277                let flag = if verb == "nudge" {
278                    "--note"
279                } else {
280                    "--reason"
281                };
282                let mut a = action(
283                    "task",
284                    format!("{verb} {}", t.id),
285                    why,
286                    format!("speccoder task {verb} {} {flag} \"…\"", t.id),
287                );
288                a = a.with_run(run);
289                a.input = Some(prompt.to_owned());
290                out.push(a);
291            }
292        }
293    }
294    if ready > 0 && !planning_next.is_empty() {
295        // Tasks exist and planning goes on: running is possible too.
296        let mut a = action(
297            "work",
298            "run the feature",
299            format!("{ready} ready task(s)"),
300            "speccoder run",
301        );
302        a = a.with_run(Runnable::Run { task: None });
303        a.blocked = run_blocked;
304        out.push(a);
305    }
306
307    // ── feature ────────────────────────────────────────────────────────
308    if feature_count > 1 {
309        let mut a = action(
310            "feature",
311            format!("switch the {unit}"),
312            format!("{feature_count} {unit}s"),
313            "speccoder feature <name>",
314        );
315        a = a.with_run(Runnable::Feature);
316        out.push(a);
317    }
318
319    // ── review: commands that leave the machine or are best read in full ─
320    // It leaves the machine: an interface confirms it first.
321    let mut pr = action(
322        "review",
323        "open a draft pull request",
324        "push the branch and its evidence; open (or update) the draft",
325        "speccoder pr --yes",
326    );
327    pr = pr.with_run(Runnable::PullRequest);
328    pr.confirm = Some(
329        "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).".to_owned(),
330    );
331    out.push(pr);
332    out.push(action(
333        "review",
334        "verify the attestations",
335        "every tick against its commit and signature",
336        "speccoder attest verify",
337    ));
338    out.push(action(
339        "review",
340        "check the setup",
341        "git, the project, the config, the model key",
342        "speccoder doctor",
343    ));
344    out
345}
346
347/// `speccoder actions`: what can be done now in the project at `path`
348/// (with `selected` the task an interface has selected) and where the
349/// feature is on its journey — the list every interface renders. Outside
350/// a project: what `report` says there, with an empty list.
351#[must_use]
352pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
353    let report = || speccoder_core::ActionsReport {
354        actions: Vec::new(),
355        journey: journey(path, feature),
356    };
357    if crate::format::at(path).detect(path).is_none() {
358        let status = crate::status::report(path, feature);
359        return CommandResult::carried("actions", &status)
360            .with_data(CommandData::Actions(Box::new(report())));
361    }
362    let mut r = report();
363    r.actions = available(path, feature, selected);
364    let message = match speccoder_core::actions::primary(&r.actions) {
365        Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
366        None => format!("{} action(s)", r.actions.len()),
367    };
368    CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
369}
370
371/// Where the feature is on its journey (spec → plan → tasks → commit → run
372/// → review); the report's, or the start of it.
373#[must_use]
374pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
375    match crate::status::report(path, feature).data() {
376        Some(CommandData::Report(r)) => r.journey.clone(),
377        _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
378    }
379}
380
381/// An interface that takes actions, and so decides which kinds it offers.
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub enum Interface {
384    /// The web host's jobs (`POST /api/run`).
385    WebJobs,
386    /// The line CLI's offer of the next step.
387    CliFollow,
388    /// The line CLI's offer of a fix speccoder knows.
389    CliRemedy,
390    /// An editor's fix, over the Agent Client Protocol.
391    AcpRemedy,
392    /// The dashboard's own keys (its `n` hands off to [`Interface::CliFollow`]).
393    Dashboard,
394}
395
396impl Interface {
397    /// Every interface.
398    pub const ALL: &'static [Self] = &[
399        Self::WebJobs,
400        Self::CliFollow,
401        Self::CliRemedy,
402        Self::AcpRemedy,
403        Self::Dashboard,
404    ];
405
406    /// What it answers for a kind it does not offer.
407    #[must_use]
408    pub fn refusal(self) -> CommandResult {
409        match self {
410            Self::CliFollow => CommandResult::new(
411                "next",
412                CommandStatus::UsageError,
413                "not a step speccoder takes here",
414            ),
415            Self::Dashboard => CommandResult::new(
416                "tui",
417                CommandStatus::UsageError,
418                "not something the dashboard does",
419            ),
420            Self::WebJobs => CommandResult::new(
421                "job",
422                CommandStatus::UsageError,
423                "not something the web app runs",
424            ),
425            Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
426                "fix",
427                CommandStatus::UsageError,
428                "not a fix speccoder applies here",
429            ),
430        }
431    }
432}
433
434/// Whether an interface takes a kind.
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum Offer {
437    /// It does.
438    Take,
439    /// It does not: it answers with [`Interface::refusal`], before anything runs.
440    Refuse,
441}
442
443/// Which kinds each interface takes: every pair decided, no default, so a
444/// new kind does not build until each interface has a decision.
445#[must_use]
446pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
447    use ActionKind as K;
448    match interface {
449        Interface::WebJobs => match kind {
450            K::Skill
451            | K::RunTask
452            | K::RunFeature
453            | K::Converge
454            | K::Commit
455            | K::Approve
456            | K::Reject
457            | K::Accept
458            | K::Skip
459            | K::Nudge
460            | K::Rule
461            | K::Review
462            | K::IssueComment
463            | K::QuickChange
464            | K::KeepSetAside
465            | K::DropSetAside
466            | K::FixSetup
467            | K::Feature
468            | K::PullRequest
469            | K::BugFix
470            | K::BugConfirm
471            | K::BugRun
472            | K::BugKeep
473            | K::AllowAlways
474            | K::WidenPolicy
475            | K::DecideBatch
476            | K::OracleTests
477            | K::ApproveAndRun
478            | K::Comment
479            | K::CommentsSend
480            | K::RevisionUndo
481            | K::CodeNudge => Offer::Take,
482            K::Command => Offer::Refuse,
483        },
484        // Decisions belong to their own prompt; what leaves the machine
485        // or asks for words is the person's own command there.
486        Interface::CliFollow => match kind {
487            K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit => Offer::Take,
488            K::Approve
489            | K::Reject
490            | K::Accept
491            | K::Skip
492            | K::Nudge
493            | K::Rule
494            | K::Review
495            | K::IssueComment
496            | K::QuickChange
497            | K::KeepSetAside
498            | K::DropSetAside
499            | K::FixSetup
500            | K::Feature
501            | K::PullRequest
502            | K::BugFix
503            | K::BugConfirm
504            | K::BugRun
505            | K::BugKeep
506            | K::AllowAlways
507            | K::WidenPolicy
508            | K::DecideBatch
509            | K::OracleTests
510            | K::ApproveAndRun
511            | K::Comment
512            | K::CommentsSend
513            | K::RevisionUndo
514            | K::CodeNudge
515            | K::Command => Offer::Refuse,
516        },
517        // Its keys decide the queue's calls and switch the feature; `n`
518        // hands the next step to the line CLI's offer.
519        Interface::Dashboard => match kind {
520            K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
521            K::Skill
522            | K::RunTask
523            | K::RunFeature
524            | K::Converge
525            | K::Commit
526            | K::Accept
527            | K::Skip
528            | K::Nudge
529            | K::Rule
530            | K::Review
531            | K::IssueComment
532            | K::QuickChange
533            | K::KeepSetAside
534            | K::DropSetAside
535            | K::FixSetup
536            | K::PullRequest
537            | K::BugFix
538            | K::BugConfirm
539            | K::BugRun
540            | K::BugKeep
541            | K::WidenPolicy
542            | K::OracleTests
543            | K::ApproveAndRun
544            | K::Comment
545            | K::CommentsSend
546            | K::RevisionUndo
547            | K::CodeNudge
548            | K::Command => Offer::Refuse,
549        },
550        Interface::CliRemedy | Interface::AcpRemedy => match kind {
551            K::FixSetup => Offer::Take,
552            K::Skill
553            | K::RunTask
554            | K::RunFeature
555            | K::Converge
556            | K::Commit
557            | K::Approve
558            | K::Reject
559            | K::Accept
560            | K::Skip
561            | K::Nudge
562            | K::Rule
563            | K::Review
564            | K::IssueComment
565            | K::QuickChange
566            | K::KeepSetAside
567            | K::DropSetAside
568            | K::Feature
569            | K::PullRequest
570            | K::BugFix
571            | K::BugConfirm
572            | K::BugRun
573            | K::BugKeep
574            | K::AllowAlways
575            | K::WidenPolicy
576            | K::DecideBatch
577            | K::OracleTests
578            | K::ApproveAndRun
579            | K::Comment
580            | K::CommentsSend
581            | K::RevisionUndo
582            | K::CodeNudge
583            | K::Command => Offer::Refuse,
584        },
585    }
586}
587
588/// An action to take: what runs, the person's input, the interface it
589/// came through and how that interface runs it (who it asks, where
590/// progress goes, its cancel flag).
591#[derive(Debug)]
592pub struct Dispatch<'a> {
593    /// What runs.
594    pub run: &'a Runnable,
595    /// The person's input (a reason, a note, a skill's arguments); blank
596    /// is none.
597    pub input: Option<&'a str>,
598    /// The interface, as what the person decides is recorded.
599    pub via: Via,
600    /// The interface's approver, progress and cancellation.
601    pub options: RunOptions,
602}
603
604impl<'a> Dispatch<'a> {
605    /// `run` through `via`, without input, asking nobody.
606    #[must_use]
607    pub fn new(run: &'a Runnable, via: Via) -> Self {
608        Self {
609            run,
610            input: None,
611            via,
612            options: RunOptions::default(),
613        }
614    }
615}
616
617/// [`perform`], if `interface` offers the action's kind; `None`, with
618/// nothing run, if it does not.
619#[must_use]
620pub fn perform_for(
621    interface: Interface,
622    root: &Path,
623    feature: Option<&str>,
624    d: Dispatch<'_>,
625) -> Option<CommandResult> {
626    (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
627}
628
629/// Takes the action: the only place a [`Runnable`] becomes runtime calls.
630/// It never prompts and never confirms (an interface has, before); what
631/// an interface does not offer it refuses before calling this
632/// ([`offered`]).
633#[must_use]
634pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
635    perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
636}
637
638/// [`perform`] with the state directory given (`None`: there is none).
639#[must_use]
640pub fn perform_in(
641    root: &Path,
642    feature: Option<&str>,
643    d: Dispatch<'_>,
644    state: Option<&Path>,
645) -> CommandResult {
646    use crate::approvals::{Choice, Verb};
647    let needs = |what: &str| {
648        CommandResult::new(
649            "job",
650            CommandStatus::UsageError,
651            &format!("{what} is needed"),
652        )
653    };
654    let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
655    let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
656    let options = &d.options;
657    let via = d.via;
658    let text = d.input.map(str::trim).filter(|t| !t.is_empty());
659    match d.run {
660        Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
661        Runnable::Run { task: Some(t) } => run_task(root, feature, t, options),
662        Runnable::Run { task: None } => run_feature(root, feature, options, false),
663        Runnable::Converge => run_feature(root, feature, options, true),
664        Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
665        Runnable::Approve { task, call, digest } => decide(Verb::Decide(
666            Choice::approve(task, text).shown(call, digest),
667        )),
668        Runnable::Reject { task, call, digest } => match text {
669            Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
670            None => needs("a reason"),
671        },
672        Runnable::Accept { task } => match (text, state) {
673            (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
674            (None, _) => needs("a reason"),
675            (_, None) => no_state(),
676        },
677        Runnable::Skip { task } => match text {
678            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
679            None => needs("a reason"),
680        },
681        Runnable::Nudge { task } => match (text, state) {
682            (Some(note), Some(s)) => {
683                crate::controls::nudge_with(root, feature, task, Some(note), s)
684            }
685            (None, _) => needs("a note"),
686            (_, None) => no_state(),
687        },
688        Runnable::Rule { task } => match (text, state) {
689            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
690            (None, _) => needs("a reason"),
691            (_, None) => no_state(),
692        },
693        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
694            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
695            ("edit" | "edit-approve", Some(id), Some(t)) => {
696                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
697            }
698            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
699            _ => crate::review::decide(root, feature, ids, verdict, text, via),
700        },
701        Runnable::IssueComment { issue, assign } => crate::github::comment(
702            &crate::github::Ctx::from_env(),
703            root,
704            feature,
705            &issue.to_string(),
706            *assign,
707        ),
708        Runnable::QuickChange {
709            request,
710            checks,
711            run,
712        } => quick_change(root, request, checks, *run, via, options),
713        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
714        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
715        Runnable::FixSetup => crate::code::fix_setup(root, feature),
716        Runnable::Feature => match text {
717            Some(name) => crate::current::switch(root, name),
718            None => needs("a feature"),
719        },
720        Runnable::BugFix {
721            symptom,
722            expected,
723            unchanged,
724            issue,
725            tests,
726            candidate,
727            slug,
728            run,
729        } => bug_fix(
730            root,
731            &crate::bug::Request::of(
732                symptom,
733                expected,
734                unchanged,
735                *issue,
736                tests,
737                candidate.as_deref(),
738                slug.as_deref(),
739                *run,
740            ),
741            via,
742            state,
743            options,
744        ),
745        Runnable::BugConfirm {
746            task,
747            digest,
748            reject,
749        } => match (*reject, text) {
750            (true, None) => needs("a reason"),
751            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
752            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
753        },
754        Runnable::BugRun { slug, task } => {
755            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
756        }
757        Runnable::BugKeep { task } => match text {
758            Some(why) => crate::bug::keep(root, task, why, state),
759            None => needs("a reason"),
760        },
761        Runnable::AllowAlways { task, call } => {
762            crate::approvals::allow_always(root, feature, task, call, state, via)
763        }
764        Runnable::WidenPolicy {
765            key,
766            prefix,
767            digest,
768        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
769        Runnable::DecideBatch {
770            run,
771            verdict,
772            calls,
773            digest,
774            resume_asking,
775        } => {
776            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
777            if !approve && text.is_none() {
778                return needs("a reason");
779            }
780            crate::approvals::decide_batch(
781                root,
782                feature,
783                crate::approvals::BatchChoice {
784                    run,
785                    approve,
786                    calls,
787                    digest,
788                    resume_asking: *resume_asking,
789                    reason: text,
790                },
791                state,
792                via,
793            )
794        }
795        Runnable::OracleTests {
796            req,
797            op,
798            path,
799            scenario,
800            reading,
801        } => crate::oracle::tests(
802            root,
803            feature,
804            &crate::oracle::Request {
805                req,
806                op: *op,
807                path: path.as_deref(),
808                scenario: scenario.as_deref(),
809                reading: *reading,
810                text: d.input.filter(|t| !t.trim().is_empty()),
811            },
812            via,
813            state,
814            options,
815        ),
816        Runnable::ApproveAndRun {
817            mode,
818            grants,
819            sheet,
820        } => crate::review::approve_and_run(
821            root,
822            feature,
823            *mode,
824            grants,
825            sheet.as_deref(),
826            via,
827            state,
828            None,
829            options,
830        ),
831        Runnable::Comment { op, anchor, id } => crate::comments::comment(
832            root,
833            feature,
834            *op,
835            anchor.as_ref(),
836            id.as_deref(),
837            text,
838            via,
839            state,
840        ),
841        Runnable::CommentsSend { ids } => {
842            crate::comments::send(root, feature, ids, via, state, options)
843        }
844        Runnable::RevisionUndo { turn } => {
845            crate::comments::undo(root, feature, turn.as_deref(), state)
846        }
847        Runnable::CodeNudge { task, comments } => {
848            crate::comments::code_nudge(root, feature, task, comments, state)
849        }
850        // The interface has confirmed it (the action's `confirm`): push
851        // and open.
852        Runnable::PullRequest => crate::handoff::pr(
853            root,
854            feature,
855            &crate::handoff::PrOptions {
856                remote: "origin",
857                base: None,
858                open: true,
859                force: false,
860                github: &crate::github::Ctx::from_env(),
861            },
862        ),
863    }
864}
865
866/// The calls a person answered Always Allow inline: each one's exact argv
867/// joins the allow list through [`perform`] with the interface it was
868/// answered through. What each did, as notices.
869#[must_use]
870pub fn allow_always_inline(
871    root: &Path,
872    feature: &str,
873    always: &[crate::substrate::Always],
874    state: &Path,
875) -> Vec<String> {
876    always
877        .iter()
878        .map(|a| {
879            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
880            let run = Runnable::AllowAlways {
881                task,
882                call: a.call.clone(),
883            };
884            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
885                .message()
886                .to_owned()
887        })
888        .collect()
889}
890
891// The arms that run the model, one function each, so a build without the
892// engine can leave them out without a second dispatcher.
893
894/// A planning skill: the person's input, else the arguments it carries.
895fn skill(
896    root: &Path,
897    feature: Option<&str>,
898    name: &str,
899    input: Option<&str>,
900    carried: &str,
901    options: &RunOptions,
902) -> CommandResult {
903    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
904}
905
906fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
907    crate::run(root, feature, task, options)
908}
909
910fn run_feature(
911    root: &Path,
912    feature: Option<&str>,
913    options: &RunOptions,
914    converge: bool,
915) -> CommandResult {
916    crate::run_feature(root, feature, options, converge)
917}
918
919fn bug_fix(
920    root: &Path,
921    req: &crate::bug::Request,
922    via: Via,
923    state: Option<&Path>,
924    options: &RunOptions,
925) -> CommandResult {
926    crate::bug::bug(root, req, via, state, options)
927}
928
929fn bug_confirm(
930    root: &Path,
931    task: &str,
932    digest: &str,
933    reject: Option<&str>,
934    via: Via,
935    state: Option<&Path>,
936    options: &RunOptions,
937) -> CommandResult {
938    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
939}
940
941fn bug_run(
942    root: &Path,
943    slug: Option<&str>,
944    task: Option<&str>,
945    state: Option<&Path>,
946    options: &RunOptions,
947) -> CommandResult {
948    crate::bug::run(root, slug, task, state, options)
949}
950
951fn quick_change(
952    root: &Path,
953    request: &str,
954    checks: &[String],
955    run: bool,
956    via: Via,
957    options: &RunOptions,
958) -> CommandResult {
959    crate::quick::change(root, request, checks, run, via, options)
960}
961
962#[cfg(test)]
963mod tests {
964    use speccoder_core::actions::primary;
965
966    use super::*;
967
968    #[test]
969    fn every_interface_decides_every_kind() {
970        let mut taken = 0;
971        for i in Interface::ALL {
972            for k in ActionKind::ALL {
973                if offered(*i, *k) == Offer::Take {
974                    taken += 1;
975                } else {
976                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
977                }
978            }
979        }
980        // The web host runs every runnable kind; the terminal the steps
981        // it offers; the remedies only the setup's fix.
982        let runnable = Runnable::examples().len();
983        // The dashboard also decides Always Allow and batches.
984        assert_eq!(taken, runnable + 5 + 5 + 2);
985        assert_eq!(
986            offered(Interface::CliFollow, ActionKind::PullRequest),
987            Offer::Refuse
988        );
989        assert_eq!(
990            offered(Interface::AcpRemedy, ActionKind::FixSetup),
991            Offer::Take
992        );
993    }
994
995    #[test]
996    fn blank_input_is_no_input() {
997        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
998        let run = Runnable::Reject {
999            task: "T001".to_owned(),
1000            call: "attempt:T001".to_owned(),
1001            digest: String::new(),
1002        };
1003        let r = perform_in(
1004            dir.path(),
1005            None,
1006            Dispatch {
1007                run: &run,
1008                input: Some("  "),
1009                via: Via::Web,
1010                options: RunOptions::default(),
1011            },
1012            None,
1013        );
1014        assert_eq!(
1015            (r.command(), r.status(), r.message()),
1016            ("job", CommandStatus::UsageError, "a reason is needed")
1017        );
1018    }
1019
1020    #[test]
1021    fn a_project_outside_git_still_gets_a_list() {
1022        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1023        let list = available(dir.path(), None, None);
1024        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1025        assert!(primary(&list).is_none());
1026    }
1027}