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    // Plan through: the rest of the chain in one action, from the files —
153    // offered after a stop as its continuation (no chain state is kept).
154    if let Some(first) = planning_next.first() {
155        let mut a = action(
156            "next",
157            "plan through to tasks",
158            "the format's own skills in order; it stops for open questions and your review",
159            format!("speccoder spec {first} --through tasks"),
160        );
161        a = a.with_run(Runnable::PlanThrough {
162            from: speccoder_core::actions::PlanFrom::Continue,
163            through: speccoder_core::actions::Through::Tasks,
164        });
165        let gate = crate::review::summary(path, feature).and_then(|r| {
166            if !r.questions.is_empty() {
167                Some(format!(
168                    "answer the {} open question{} first",
169                    r.questions.len(),
170                    if r.questions.len() == 1 { "" } else { "s" }
171                ))
172            } else {
173                (!r.requirements.is_empty() && !r.totals.complete())
174                    .then(|| "approve the spec's requirements first (Review)".to_owned())
175            }
176        });
177        a.blocked = gate;
178        out.push(a);
179    }
180    for name in &planning_next {
181        let why = match name.as_str() {
182            "plan" => "write the plan from the spec",
183            "tasks" => "break the plan into tasks",
184            _ => "the next planning step",
185        };
186        out.push(skill("next", name, why));
187    }
188    if !uncommitted.is_empty() {
189        let mut a = action(
190            "next",
191            format!("commit the planning files ({})", uncommitted.len()),
192            "speccoder runs from committed files",
193            format!(
194                "git add {0} && git commit -m \"spec: …\" -- {0}",
195                uncommitted.join(" ")
196            ),
197        );
198        a = a.with_run(Runnable::Commit {
199            paths: uncommitted.clone(),
200        });
201        out.push(a);
202    }
203    let ready = report.map_or(0, |r| r.totals.ready);
204    let run_blocked = (!uncommitted.is_empty())
205        .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
206    if ready > 0 && planning_next.is_empty() {
207        let mut a = action(
208            "next",
209            "run the feature",
210            format!("{ready} ready task(s), each judged on evidence"),
211            "speccoder run",
212        );
213        a = a.with_run(Runnable::Run { task: None });
214        a.blocked = run_blocked.clone();
215        out.push(a);
216    }
217    // The spec changed under ticks that cite it: their evidence is for the
218    // earlier wording. Convergence assesses the code against it as it is.
219    let stale: Vec<&str> = report
220        .map(|r| {
221            r.tasks
222                .iter()
223                .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
224                .map(|t| t.id.as_str())
225                .collect()
226        })
227        .unwrap_or_default();
228    if !stale.is_empty() && planning_next.is_empty() {
229        let mut a = action(
230            "next",
231            "re-check what the spec changed",
232            format!(
233                "{} 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",
234                stale.join(", ")
235            ),
236            "speccoder run --converge",
237        );
238        a = a.with_run(Runnable::Converge);
239        a.blocked = run_blocked.clone();
240        out.push(a);
241    }
242    if report.is_none()
243        && feature_count == 0
244        && format.detect(path).is_some()
245        && let Some(create) = planning.creates.first()
246    {
247        out.push(skill(
248            "next",
249            create,
250            &format!("write the first {unit}'s spec"),
251        ));
252    }
253
254    // ── plan: every hosted planning skill ─────────────────────────────
255    for s in skills.iter().filter(|s| s.refused.is_none()) {
256        let name = short(&s.name);
257        if planning_next.contains(&name) {
258            continue;
259        }
260        out.push(skill("plan", &name, &s.description));
261    }
262
263    // ── work ───────────────────────────────────────────────────────────
264    if let Some(t) =
265        selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
266    {
267        if t.state == "ready" {
268            let mut a = action(
269                "work",
270                format!("run {}", t.id),
271                t.title.clone(),
272                format!("speccoder run --task {}", t.id),
273            );
274            a = a.with_run(Runnable::Run {
275                task: Some(t.id.clone()),
276            });
277            a.blocked = run_blocked.clone();
278            out.push(a);
279        }
280        // ── task: the selected one ────────────────────────────────────
281        if !matches!(
282            t.state.as_str(),
283            "verified" | "regression_only" | "attested"
284        ) {
285            for (verb, why, prompt, run) in [
286                (
287                    "accept",
288                    "tick it on your word (attested, not verified)",
289                    "Why it is done",
290                    Runnable::Accept { task: t.id.clone() },
291                ),
292                (
293                    "skip",
294                    "runs pass it by; what depends on it stays blocked",
295                    "Why it is skipped",
296                    Runnable::Skip { task: t.id.clone() },
297                ),
298                (
299                    "nudge",
300                    "a note its next attempts read",
301                    "The note",
302                    Runnable::Nudge { task: t.id.clone() },
303                ),
304            ] {
305                let flag = if verb == "nudge" {
306                    "--note"
307                } else {
308                    "--reason"
309                };
310                let mut a = action(
311                    "task",
312                    format!("{verb} {}", t.id),
313                    why,
314                    format!("speccoder task {verb} {} {flag} \"…\"", t.id),
315                );
316                a = a.with_run(run);
317                a.input = Some(prompt.to_owned());
318                out.push(a);
319            }
320        }
321    }
322    if ready > 0 && !planning_next.is_empty() {
323        // Tasks exist and planning goes on: running is possible too.
324        let mut a = action(
325            "work",
326            "run the feature",
327            format!("{ready} ready task(s)"),
328            "speccoder run",
329        );
330        a = a.with_run(Runnable::Run { task: None });
331        a.blocked = run_blocked;
332        out.push(a);
333    }
334
335    // ── feature ────────────────────────────────────────────────────────
336    if feature_count > 1 {
337        let mut a = action(
338            "feature",
339            format!("switch the {unit}"),
340            format!("{feature_count} {unit}s"),
341            "speccoder feature <name>",
342        );
343        a = a.with_run(Runnable::Feature);
344        out.push(a);
345    }
346
347    // ── review: commands that leave the machine or are best read in full ─
348    // It leaves the machine: an interface confirms it first.
349    let mut pr = action(
350        "review",
351        "open a draft pull request",
352        "push the branch and its evidence; open (or update) the draft",
353        "speccoder pr --yes",
354    );
355    pr = pr.with_run(Runnable::PullRequest);
356    pr.confirm = Some(
357        "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(),
358    );
359    out.push(pr);
360    out.push(action(
361        "review",
362        "verify the attestations",
363        "every tick against its commit and signature",
364        "speccoder attest verify",
365    ));
366    out.push(action(
367        "review",
368        "check the setup",
369        "git, the project, the config, the model key",
370        "speccoder doctor",
371    ));
372    out
373}
374
375/// `speccoder actions`: what can be done now in the project at `path`
376/// (with `selected` the task an interface has selected) and where the
377/// feature is on its journey — the list every interface renders. Outside
378/// a project: what `report` says there, with an empty list.
379#[must_use]
380pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
381    let report = || speccoder_core::ActionsReport {
382        actions: Vec::new(),
383        journey: journey(path, feature),
384    };
385    if crate::format::at(path).detect(path).is_none() {
386        let status = crate::status::report(path, feature);
387        return CommandResult::carried("actions", &status)
388            .with_data(CommandData::Actions(Box::new(report())));
389    }
390    let mut r = report();
391    r.actions = available(path, feature, selected);
392    let message = match speccoder_core::actions::primary(&r.actions) {
393        Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
394        None => format!("{} action(s)", r.actions.len()),
395    };
396    CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
397}
398
399/// Where the feature is on its journey (spec → plan → tasks → commit → run
400/// → review); the report's, or the start of it.
401#[must_use]
402pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
403    match crate::status::report(path, feature).data() {
404        Some(CommandData::Report(r)) => r.journey.clone(),
405        _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
406    }
407}
408
409/// An interface that takes actions, and so decides which kinds it offers.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum Interface {
412    /// The web host's jobs (`POST /api/run`).
413    WebJobs,
414    /// The line CLI's offer of the next step.
415    CliFollow,
416    /// The line CLI's offer of a fix speccoder knows.
417    CliRemedy,
418    /// An editor's fix, over the Agent Client Protocol.
419    AcpRemedy,
420    /// The dashboard's own keys (its `n` hands off to [`Interface::CliFollow`]).
421    Dashboard,
422}
423
424impl Interface {
425    /// Every interface.
426    pub const ALL: &'static [Self] = &[
427        Self::WebJobs,
428        Self::CliFollow,
429        Self::CliRemedy,
430        Self::AcpRemedy,
431        Self::Dashboard,
432    ];
433
434    /// What it answers for a kind it does not offer.
435    #[must_use]
436    pub fn refusal(self) -> CommandResult {
437        match self {
438            Self::CliFollow => CommandResult::new(
439                "next",
440                CommandStatus::UsageError,
441                "not a step speccoder takes here",
442            ),
443            Self::Dashboard => CommandResult::new(
444                "tui",
445                CommandStatus::UsageError,
446                "not something the dashboard does",
447            ),
448            Self::WebJobs => CommandResult::new(
449                "job",
450                CommandStatus::UsageError,
451                "not something the web app runs",
452            ),
453            Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
454                "fix",
455                CommandStatus::UsageError,
456                "not a fix speccoder applies here",
457            ),
458        }
459    }
460}
461
462/// Whether an interface takes a kind.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum Offer {
465    /// It does.
466    Take,
467    /// It does not: it answers with [`Interface::refusal`], before anything runs.
468    Refuse,
469}
470
471/// Which kinds each interface takes: every pair decided, no default, so a
472/// new kind does not build until each interface has a decision.
473#[must_use]
474pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
475    use ActionKind as K;
476    match interface {
477        Interface::WebJobs => match kind {
478            K::Skill
479            | K::RunTask
480            | K::RunFeature
481            | K::Converge
482            | K::Commit
483            | K::Approve
484            | K::Reject
485            | K::Accept
486            | K::Skip
487            | K::Nudge
488            | K::Rule
489            | K::Review
490            | K::IssueComment
491            | K::QuickChange
492            | K::KeepSetAside
493            | K::DropSetAside
494            | K::FixSetup
495            | K::Feature
496            | K::PullRequest
497            | K::BugFix
498            | K::BugConfirm
499            | K::BugRun
500            | K::BugKeep
501            | K::AllowAlways
502            | K::WidenPolicy
503            | K::DecideBatch
504            | K::OracleTests
505            | K::ApproveAndRun
506            | K::Comment
507            | K::CommentsSend
508            | K::RevisionUndo
509            | K::CodeNudge
510            | K::AcceptanceAt
511            | K::PresetAdd
512            | K::PlanThrough
513            | K::NewProject
514            | K::UndoNewProject
515            | K::CloneRepository
516            | K::OpenSample
517            | K::SampleChecks => Offer::Take,
518            K::Command => Offer::Refuse,
519        },
520        // Decisions belong to their own prompt; what leaves the machine
521        // or asks for words is the person's own command there.
522        Interface::CliFollow => match kind {
523            // Plan through's continuation is the `Next:` step after a stop.
524            K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
525                Offer::Take
526            }
527            K::Approve
528            | K::Reject
529            | K::Accept
530            | K::Skip
531            | K::Nudge
532            | K::Rule
533            | K::Review
534            | K::IssueComment
535            | K::QuickChange
536            | K::KeepSetAside
537            | K::DropSetAside
538            | K::FixSetup
539            | K::Feature
540            | K::PullRequest
541            | K::BugFix
542            | K::BugConfirm
543            | K::BugRun
544            | K::BugKeep
545            | K::AllowAlways
546            | K::WidenPolicy
547            | K::DecideBatch
548            | K::OracleTests
549            | K::ApproveAndRun
550            | K::Comment
551            | K::CommentsSend
552            | K::RevisionUndo
553            | K::CodeNudge
554            | K::AcceptanceAt
555            | K::PresetAdd
556            | K::NewProject
557            | K::UndoNewProject
558            | K::CloneRepository
559            | K::OpenSample
560            | K::SampleChecks
561            | K::Command => Offer::Refuse,
562        },
563        // Its keys decide the queue's calls and switch the feature; `n`
564        // hands the next step to the line CLI's offer.
565        Interface::Dashboard => match kind {
566            K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
567            K::Skill
568            | K::RunTask
569            | K::RunFeature
570            | K::Converge
571            | K::Commit
572            | K::Accept
573            | K::Skip
574            | K::Nudge
575            | K::Rule
576            | K::Review
577            | K::IssueComment
578            | K::QuickChange
579            | K::KeepSetAside
580            | K::DropSetAside
581            | K::FixSetup
582            | K::PullRequest
583            | K::BugFix
584            | K::BugConfirm
585            | K::BugRun
586            | K::BugKeep
587            | K::WidenPolicy
588            | K::OracleTests
589            | K::ApproveAndRun
590            | K::Comment
591            | K::CommentsSend
592            | K::RevisionUndo
593            | K::CodeNudge
594            | K::AcceptanceAt
595            | K::PresetAdd
596            | K::PlanThrough
597            | K::NewProject
598            | K::UndoNewProject
599            | K::CloneRepository
600            | K::OpenSample
601            | K::SampleChecks
602            | K::Command => Offer::Refuse,
603        },
604        Interface::CliRemedy | Interface::AcpRemedy => match kind {
605            K::FixSetup => Offer::Take,
606            K::Skill
607            | K::RunTask
608            | K::RunFeature
609            | K::Converge
610            | K::Commit
611            | K::Approve
612            | K::Reject
613            | K::Accept
614            | K::Skip
615            | K::Nudge
616            | K::Rule
617            | K::Review
618            | K::IssueComment
619            | K::QuickChange
620            | K::KeepSetAside
621            | K::DropSetAside
622            | K::Feature
623            | K::PullRequest
624            | K::BugFix
625            | K::BugConfirm
626            | K::BugRun
627            | K::BugKeep
628            | K::AllowAlways
629            | K::WidenPolicy
630            | K::DecideBatch
631            | K::OracleTests
632            | K::ApproveAndRun
633            | K::Comment
634            | K::CommentsSend
635            | K::RevisionUndo
636            | K::CodeNudge
637            | K::AcceptanceAt
638            | K::PresetAdd
639            | K::PlanThrough
640            | K::NewProject
641            | K::UndoNewProject
642            | K::CloneRepository
643            | K::OpenSample
644            | K::SampleChecks
645            | K::Command => Offer::Refuse,
646        },
647    }
648}
649
650/// An action to take: what runs, the person's input, the interface it
651/// came through and how that interface runs it (who it asks, where
652/// progress goes, its cancel flag).
653#[derive(Debug)]
654pub struct Dispatch<'a> {
655    /// What runs.
656    pub run: &'a Runnable,
657    /// The person's input (a reason, a note, a skill's arguments); blank
658    /// is none.
659    pub input: Option<&'a str>,
660    /// The interface, as what the person decides is recorded.
661    pub via: Via,
662    /// The interface's approver, progress and cancellation.
663    pub options: RunOptions,
664}
665
666impl<'a> Dispatch<'a> {
667    /// `run` through `via`, without input, asking nobody.
668    #[must_use]
669    pub fn new(run: &'a Runnable, via: Via) -> Self {
670        Self {
671            run,
672            input: None,
673            via,
674            options: RunOptions::default(),
675        }
676    }
677}
678
679/// [`perform`], if `interface` offers the action's kind; `None`, with
680/// nothing run, if it does not.
681#[must_use]
682pub fn perform_for(
683    interface: Interface,
684    root: &Path,
685    feature: Option<&str>,
686    d: Dispatch<'_>,
687) -> Option<CommandResult> {
688    (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
689}
690
691/// Takes the action: the only place a [`Runnable`] becomes runtime calls.
692/// It never prompts and never confirms (an interface has, before); what
693/// an interface does not offer it refuses before calling this
694/// ([`offered`]).
695#[must_use]
696pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
697    perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
698}
699
700/// [`perform`] with the state directory given (`None`: there is none).
701#[must_use]
702pub fn perform_in(
703    root: &Path,
704    feature: Option<&str>,
705    d: Dispatch<'_>,
706    state: Option<&Path>,
707) -> CommandResult {
708    use crate::approvals::{Choice, Verb};
709    let needs = |what: &str| {
710        CommandResult::new(
711            "job",
712            CommandStatus::UsageError,
713            &format!("{what} is needed"),
714        )
715    };
716    let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
717    let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
718    let options = &d.options;
719    let via = d.via;
720    let text = d.input.map(str::trim).filter(|t| !t.is_empty());
721    match d.run {
722        Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
723        Runnable::Run { task: Some(t) } => run_task(root, feature, t, options),
724        Runnable::Run { task: None } => run_feature(root, feature, options, false),
725        Runnable::Converge => run_feature(root, feature, options, true),
726        Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
727        Runnable::Approve { task, call, digest } => decide(Verb::Decide(
728            Choice::approve(task, text).shown(call, digest),
729        )),
730        Runnable::Reject { task, call, digest } => match text {
731            Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
732            None => needs("a reason"),
733        },
734        Runnable::Accept { task } => match (text, state) {
735            (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
736            (None, _) => needs("a reason"),
737            (_, None) => no_state(),
738        },
739        // A decision recorded in the state directory, never the spec.
740        Runnable::AcceptanceAt {
741            feature: unit,
742            story,
743            task,
744            revoke,
745        } => match state {
746            Some(s) => {
747                crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
748            }
749            None => no_state(),
750        },
751        // The interface has confirmed: Spec Kit's own command runs.
752        Runnable::PresetAdd { update } => match state {
753            Some(s) => crate::preset::add(root, *update, true, s),
754            None => no_state(),
755        },
756        Runnable::Skip { task } => match text {
757            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
758            None => needs("a reason"),
759        },
760        Runnable::Nudge { task } => match (text, state) {
761            (Some(note), Some(s)) => {
762                crate::controls::nudge_with(root, feature, task, Some(note), s)
763            }
764            (None, _) => needs("a note"),
765            (_, None) => no_state(),
766        },
767        Runnable::Rule { task } => match (text, state) {
768            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
769            (None, _) => needs("a reason"),
770            (_, None) => no_state(),
771        },
772        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
773            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
774            ("edit" | "edit-approve", Some(id), Some(t)) => {
775                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
776            }
777            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
778            _ => crate::review::decide(root, feature, ids, verdict, text, via),
779        },
780        Runnable::IssueComment { issue, assign } => crate::github::comment(
781            &crate::github::Ctx::from_env(),
782            root,
783            feature,
784            &issue.to_string(),
785            *assign,
786        ),
787        Runnable::QuickChange {
788            request,
789            checks,
790            run,
791        } => quick_change(root, request, checks, *run, via, options),
792        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
793        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
794        Runnable::FixSetup => crate::code::fix_setup(root, feature),
795        Runnable::Feature => match text {
796            Some(name) => crate::current::switch(root, name),
797            None => needs("a feature"),
798        },
799        Runnable::BugFix {
800            symptom,
801            expected,
802            unchanged,
803            issue,
804            tests,
805            candidate,
806            slug,
807            run,
808        } => bug_fix(
809            root,
810            &crate::bug::Request::of(
811                symptom,
812                expected,
813                unchanged,
814                *issue,
815                tests,
816                candidate.as_deref(),
817                slug.as_deref(),
818                *run,
819            ),
820            via,
821            state,
822            options,
823        ),
824        Runnable::BugConfirm {
825            task,
826            digest,
827            reject,
828        } => match (*reject, text) {
829            (true, None) => needs("a reason"),
830            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
831            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
832        },
833        Runnable::BugRun { slug, task } => {
834            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
835        }
836        Runnable::BugKeep { task } => match text {
837            Some(why) => crate::bug::keep(root, task, why, state),
838            None => needs("a reason"),
839        },
840        Runnable::AllowAlways { task, call } => {
841            crate::approvals::allow_always(root, feature, task, call, state, via)
842        }
843        Runnable::WidenPolicy {
844            key,
845            prefix,
846            digest,
847        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
848        Runnable::DecideBatch {
849            run,
850            verdict,
851            calls,
852            digest,
853            resume_asking,
854        } => {
855            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
856            if !approve && text.is_none() {
857                return needs("a reason");
858            }
859            crate::approvals::decide_batch(
860                root,
861                feature,
862                crate::approvals::BatchChoice {
863                    run,
864                    approve,
865                    calls,
866                    digest,
867                    resume_asking: *resume_asking,
868                    reason: text,
869                },
870                state,
871                via,
872            )
873        }
874        Runnable::OracleTests {
875            req,
876            op,
877            path,
878            scenario,
879            reading,
880        } => crate::oracle::tests(
881            root,
882            feature,
883            &crate::oracle::Request {
884                req,
885                op: *op,
886                path: path.as_deref(),
887                scenario: scenario.as_deref(),
888                reading: *reading,
889                text: d.input.filter(|t| !t.trim().is_empty()),
890            },
891            via,
892            state,
893            options,
894        ),
895        Runnable::ApproveAndRun {
896            mode,
897            grants,
898            sheet,
899        } => crate::review::approve_and_run(
900            root,
901            feature,
902            *mode,
903            grants,
904            sheet.as_deref(),
905            via,
906            state,
907            None,
908            options,
909        ),
910        Runnable::Comment { op, anchor, id } => crate::comments::comment(
911            root,
912            feature,
913            *op,
914            anchor.as_ref(),
915            id.as_deref(),
916            text,
917            via,
918            state,
919        ),
920        Runnable::CommentsSend { ids } => {
921            crate::comments::send(root, feature, ids, via, state, options)
922        }
923        Runnable::RevisionUndo { turn } => {
924            crate::comments::undo(root, feature, turn.as_deref(), state)
925        }
926        Runnable::CodeNudge { task, comments } => {
927            crate::comments::code_nudge(root, feature, task, comments, state)
928        }
929        // The format's own skills, in its order; the stops are read from
930        // the unit's files, so nothing of the chain is kept here.
931        Runnable::PlanThrough {
932            from:
933                speccoder_core::actions::PlanFrom::Create {
934                    description,
935                    source,
936                },
937            through,
938        } => {
939            let description = if description.trim().is_empty() {
940                text.unwrap_or_default()
941            } else {
942                description.as_str()
943            };
944            plan_through(root, description, source, *through, options)
945        }
946        Runnable::PlanThrough {
947            from: speccoder_core::actions::PlanFrom::Continue,
948            through,
949        } => crate::planning_chain::resume(root, feature, *through, options),
950        // The plan the person confirmed, recomputed and compared first.
951        Runnable::NewProject {
952            target,
953            choices,
954            plan_digest,
955        } => match state {
956            Some(s) => crate::new_project::perform(
957                Path::new(target),
958                choices,
959                plan_digest,
960                via,
961                s,
962                options,
963            ),
964            None => no_state(),
965        },
966        Runnable::UndoNewProject { commit } => match state {
967            Some(s) => crate::new_project::undo(root, commit, via, s, options),
968            None => no_state(),
969        },
970        // The interface has confirmed it names github.com.
971        Runnable::CloneRepository { repo, target } => {
972            crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
973        }
974        Runnable::OpenSample { fresh } => match state {
975            Some(s) => crate::sample::open(s, *fresh),
976            None => no_state(),
977        },
978        Runnable::SampleChecks => match state {
979            Some(s) => crate::sample::run_checks(s, root),
980            None => no_state(),
981        },
982        // The interface has confirmed it (the action's `confirm`): push
983        // and open.
984        Runnable::PullRequest => crate::handoff::pr(
985            root,
986            feature,
987            &crate::handoff::PrOptions {
988                remote: "origin",
989                base: None,
990                open: true,
991                force: false,
992                github: &crate::github::Ctx::from_env(),
993            },
994        ),
995    }
996}
997
998/// The calls a person answered Always Allow inline: each one's exact argv
999/// joins the allow list through [`perform`] with the interface it was
1000/// answered through. What each did, as notices.
1001#[must_use]
1002pub fn allow_always_inline(
1003    root: &Path,
1004    feature: &str,
1005    always: &[crate::substrate::Always],
1006    state: &Path,
1007) -> Vec<String> {
1008    always
1009        .iter()
1010        .map(|a| {
1011            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1012            let run = Runnable::AllowAlways {
1013                task,
1014                call: a.call.clone(),
1015            };
1016            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1017                .message()
1018                .to_owned()
1019        })
1020        .collect()
1021}
1022
1023// The arms that run the model, one function each, so a build without the
1024// engine can leave them out without a second dispatcher.
1025
1026/// A planning skill: the person's input, else the arguments it carries.
1027fn skill(
1028    root: &Path,
1029    feature: Option<&str>,
1030    name: &str,
1031    input: Option<&str>,
1032    carried: &str,
1033    options: &RunOptions,
1034) -> CommandResult {
1035    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1036}
1037
1038fn plan_through(
1039    root: &Path,
1040    description: &str,
1041    source: &speccoder_core::actions::FeatureSource,
1042    through: speccoder_core::actions::Through,
1043    options: &RunOptions,
1044) -> CommandResult {
1045    crate::planning_chain::start(root, description, source, through, options)
1046}
1047
1048fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1049    crate::run(root, feature, task, options)
1050}
1051
1052fn run_feature(
1053    root: &Path,
1054    feature: Option<&str>,
1055    options: &RunOptions,
1056    converge: bool,
1057) -> CommandResult {
1058    crate::run_feature(root, feature, options, converge)
1059}
1060
1061fn bug_fix(
1062    root: &Path,
1063    req: &crate::bug::Request,
1064    via: Via,
1065    state: Option<&Path>,
1066    options: &RunOptions,
1067) -> CommandResult {
1068    crate::bug::bug(root, req, via, state, options)
1069}
1070
1071fn bug_confirm(
1072    root: &Path,
1073    task: &str,
1074    digest: &str,
1075    reject: Option<&str>,
1076    via: Via,
1077    state: Option<&Path>,
1078    options: &RunOptions,
1079) -> CommandResult {
1080    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1081}
1082
1083fn bug_run(
1084    root: &Path,
1085    slug: Option<&str>,
1086    task: Option<&str>,
1087    state: Option<&Path>,
1088    options: &RunOptions,
1089) -> CommandResult {
1090    crate::bug::run(root, slug, task, state, options)
1091}
1092
1093fn quick_change(
1094    root: &Path,
1095    request: &str,
1096    checks: &[String],
1097    run: bool,
1098    via: Via,
1099    options: &RunOptions,
1100) -> CommandResult {
1101    crate::quick::change(root, request, checks, run, via, options)
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use speccoder_core::actions::primary;
1107
1108    use super::*;
1109
1110    #[test]
1111    fn every_interface_decides_every_kind() {
1112        let mut taken = 0;
1113        for i in Interface::ALL {
1114            for k in ActionKind::ALL {
1115                if offered(*i, *k) == Offer::Take {
1116                    taken += 1;
1117                } else {
1118                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1119                }
1120            }
1121        }
1122        // The web host runs every runnable kind; the terminal the steps
1123        // it offers (plan through's continuation among them); the remedies
1124        // only the setup's fix.
1125        let runnable = Runnable::examples().len();
1126        // The dashboard also decides Always Allow and batches.
1127        assert_eq!(taken, runnable + 6 + 5 + 2);
1128        assert_eq!(
1129            offered(Interface::CliFollow, ActionKind::PullRequest),
1130            Offer::Refuse
1131        );
1132        assert_eq!(
1133            offered(Interface::AcpRemedy, ActionKind::FixSetup),
1134            Offer::Take
1135        );
1136    }
1137
1138    #[test]
1139    fn blank_input_is_no_input() {
1140        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1141        let run = Runnable::Reject {
1142            task: "T001".to_owned(),
1143            call: "attempt:T001".to_owned(),
1144            digest: String::new(),
1145        };
1146        let r = perform_in(
1147            dir.path(),
1148            None,
1149            Dispatch {
1150                run: &run,
1151                input: Some("  "),
1152                via: Via::Web,
1153                options: RunOptions::default(),
1154            },
1155            None,
1156        );
1157        assert_eq!(
1158            (r.command(), r.status(), r.message()),
1159            ("job", CommandStatus::UsageError, "a reason is needed")
1160        );
1161    }
1162
1163    #[test]
1164    fn a_project_outside_git_still_gets_a_list() {
1165        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1166        let list = available(dir.path(), None, None);
1167        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1168        assert!(primary(&list).is_none());
1169    }
1170}