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
42/// How an interface takes a line speccoder printed — a fix or a next
43/// step: performed through [`perform`], a view, the person's own command,
44/// or a line for their shell. The one parser every builder's lines go
45/// through (in their serialisation).
46pub use speccoder_core::step::{Context, How, Step, View, step_of as runnable_of};
47
48pub(crate) fn action(
49    group: &str,
50    label: impl Into<String>,
51    why: impl Into<String>,
52    command: impl Into<String>,
53) -> Action {
54    Action::new(group, label, why, command)
55}
56
57/// The reason an I-did-it tick records when the person gave none.
58pub const DONE_REASON: &str = "I did it";
59
60/// What can be done now in the project at `path` (`feature` as for every
61/// command), with `selected` the task an interface has selected.
62#[must_use]
63pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
64    available_with(path, feature, selected, None)
65}
66
67/// [`available`] with the waiting calls already listed (an interface that
68/// lists them anyway): the journal is not opened again.
69#[must_use]
70pub fn available_with(
71    path: &Path,
72    feature: Option<&str>,
73    selected: Option<&str>,
74    listed: Option<&ApprovalsReport>,
75) -> Vec<Action> {
76    let report = crate::status::report(path, feature);
77    let report: Option<&StatusReport> = match report.data() {
78        Some(CommandData::Report(r)) => Some(r),
79        _ => None,
80    };
81    let approvals = listed.cloned().or_else(|| {
82        crate::worktree::state_dir().and_then(|state| {
83            match crate::approvals::list_with(path, feature, &state).data() {
84                Some(CommandData::Approvals(a)) => Some((**a).clone()),
85                _ => None,
86            }
87        })
88    });
89    let uncommitted = crate::hosting::uncommitted(path, feature);
90    let skills = match crate::hosting::list(path).data() {
91        Some(CommandData::Skills(s)) => s.skills.clone(),
92        _ => Vec::new(),
93    };
94    let format = crate::format::at(path);
95    let planning = format.planning();
96    let unit = format.messages().unit;
97    let features = crate::current::list(path, None);
98    let feature_count = match features.data() {
99        Some(CommandData::Features(f)) => f.features.len(),
100        _ => 0,
101    };
102    let short = |name: &str| {
103        name.strip_prefix(planning.prefix)
104            .unwrap_or(name)
105            .to_owned()
106    };
107    let skill = |group: &str, name: &str, why: &str| {
108        let creates = planning.creates.contains(&name);
109        let mut a = action(
110            group,
111            format!("spec {name}"),
112            why,
113            if creates {
114                format!("speccoder spec {name} \"…\"")
115            } else {
116                format!("speccoder spec {name}")
117            },
118        );
119        a = a
120            .with_run(Runnable::Skill {
121                name: name.to_owned(),
122                arguments: String::new(),
123            })
124            .with_role(planning.role_of(name));
125        if creates {
126            a.input = Some(format!("Describe the {unit}"));
127        }
128        a
129    };
130    let mut out = Vec::new();
131
132    // ── next: what moves the work forward, most urgent first ──────────
133    for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
134        let mut a = action(
135            "next",
136            format!("approve {}'s waiting call", p.task),
137            format!("{} — {}", p.covers, p.reason),
138            format!("speccoder task approve {}", p.task),
139        );
140        a = a.with_run(Runnable::Approve {
141            task: p.task.clone(),
142            call: p.id(),
143            digest: p.digest.clone(),
144        });
145        out.push(a);
146        let mut r = action(
147            "next",
148            format!("reject {}'s waiting call", p.task),
149            "the model is told your reason",
150            format!("speccoder task reject {} --reason \"…\"", p.task),
151        );
152        r = r.with_run(Runnable::Reject {
153            task: p.task.clone(),
154            call: p.id(),
155            digest: p.digest.clone(),
156        });
157        r.input = Some("Why (the model is told)".to_owned());
158        out.push(r);
159    }
160    let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
161    // Plan through: the rest of the chain in one action, from the files —
162    // offered after a stop as its continuation (no chain state is kept).
163    if let Some(first) = planning_next.first() {
164        let mut a = action(
165            "next",
166            "plan through to tasks",
167            "the format's own skills in order; it stops for open questions and your review",
168            format!("speccoder spec {first} --through tasks"),
169        );
170        a = a.with_run(Runnable::PlanThrough {
171            from: speccoder_core::actions::PlanFrom::Continue,
172            through: speccoder_core::actions::Through::Tasks,
173        });
174        let gate = crate::review::summary(path, feature).and_then(|r| {
175            if !r.questions.is_empty() {
176                Some(format!(
177                    "answer the {} open question{} first",
178                    r.questions.len(),
179                    if r.questions.len() == 1 { "" } else { "s" }
180                ))
181            } else {
182                (!r.requirements.is_empty() && !r.totals.complete())
183                    .then(|| "approve the spec's requirements first (Review)".to_owned())
184            }
185        });
186        a.blocked = gate;
187        out.push(a);
188    }
189    for name in &planning_next {
190        let why = match name.as_str() {
191            "plan" => "write the plan from the spec",
192            "tasks" => "break the plan into tasks",
193            _ => "the next planning step",
194        };
195        out.push(skill("next", name, why));
196    }
197    if !uncommitted.is_empty() {
198        let mut a = action(
199            "next",
200            format!("commit the planning files ({})", uncommitted.len()),
201            "speccoder runs from committed files",
202            format!(
203                "git add {0} && git commit -m \"spec: …\" -- {0}",
204                uncommitted.join(" ")
205            ),
206        );
207        a = a.with_run(Runnable::Commit {
208            paths: uncommitted.clone(),
209        });
210        out.push(a);
211    }
212    let ready = report.map_or(0, |r| r.totals.ready);
213    let run_blocked = (!uncommitted.is_empty())
214        .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
215    if ready > 0 && planning_next.is_empty() {
216        let mut a = action(
217            "next",
218            "run the feature",
219            format!("{ready} ready task(s), each judged on evidence"),
220            "speccoder run",
221        );
222        a = a.with_run(Runnable::Run {
223            task: None,
224            agent: None,
225        });
226        a.blocked = run_blocked.clone();
227        out.push(a);
228    }
229    // The spec changed under ticks that cite it: their evidence is for the
230    // earlier wording. Convergence assesses the code against it as it is.
231    let stale: Vec<&str> = report
232        .map(|r| {
233            r.tasks
234                .iter()
235                .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
236                .map(|t| t.id.as_str())
237                .collect()
238        })
239        .unwrap_or_default();
240    if !stale.is_empty() && planning_next.is_empty() {
241        let mut a = action(
242            "next",
243            "re-check what the spec changed",
244            format!(
245                "{} 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",
246                stale.join(", ")
247            ),
248            "speccoder run --converge",
249        );
250        a = a.with_run(Runnable::Converge);
251        a.blocked = run_blocked.clone();
252        out.push(a);
253    }
254    if report.is_none()
255        && feature_count == 0
256        && format.detect(path).is_some()
257        && let Some(create) = planning.creates.first()
258    {
259        out.push(skill(
260            "next",
261            create,
262            &format!("write the first {unit}'s spec"),
263        ));
264    }
265
266    // ── plan: every hosted planning skill ─────────────────────────────
267    for s in skills.iter().filter(|s| s.refused.is_none()) {
268        let name = short(&s.name);
269        if planning_next.contains(&name) {
270            continue;
271        }
272        out.push(skill("plan", &name, &s.description));
273    }
274
275    // ── work ───────────────────────────────────────────────────────────
276    if let Some(t) =
277        selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
278    {
279        if t.state == "ready" {
280            let mut a = action(
281                "work",
282                format!("run {}", t.id),
283                t.title.clone(),
284                format!("speccoder run --task {}", t.id),
285            );
286            a = a.with_run(Runnable::Run {
287                task: Some(t.id.clone()),
288                agent: None,
289            });
290            a.blocked = run_blocked.clone();
291            out.push(a);
292        }
293        // ── task: the selected one ────────────────────────────────────
294        if !matches!(
295            t.state.as_str(),
296            "verified" | "regression_only" | "attested"
297        ) {
298            for (verb, why, prompt, run) in [
299                (
300                    "accept",
301                    "tick it on your word (attested, not verified)",
302                    "Why it is done",
303                    Runnable::Accept { task: t.id.clone() },
304                ),
305                (
306                    "skip",
307                    "runs pass it by; what depends on it stays blocked",
308                    "Why it is skipped",
309                    Runnable::Skip { task: t.id.clone() },
310                ),
311                (
312                    "nudge",
313                    "a note its next attempts read",
314                    "The note",
315                    Runnable::Nudge { task: t.id.clone() },
316                ),
317            ] {
318                let flag = if verb == "nudge" {
319                    "--note"
320                } else {
321                    "--reason"
322                };
323                let mut a = action(
324                    "task",
325                    format!("{verb} {}", t.id),
326                    why,
327                    format!("speccoder task {verb} {} {flag} \"…\"", t.id),
328                );
329                a = a.with_run(run);
330                a.input = Some(prompt.to_owned());
331                out.push(a);
332            }
333        }
334    }
335    if ready > 0 && !planning_next.is_empty() {
336        // Tasks exist and planning goes on: running is possible too.
337        let mut a = action(
338            "work",
339            "run the feature",
340            format!("{ready} ready task(s)"),
341            "speccoder run",
342        );
343        a = a.with_run(Runnable::Run {
344            task: None,
345            agent: None,
346        });
347        a.blocked = run_blocked;
348        out.push(a);
349    }
350
351    // ── feature ────────────────────────────────────────────────────────
352    if feature_count > 1 {
353        let mut a = action(
354            "feature",
355            format!("switch the {unit}"),
356            format!("{feature_count} {unit}s"),
357            "speccoder feature <name>",
358        );
359        a = a.with_run(Runnable::Feature);
360        out.push(a);
361    }
362
363    // ── review: commands that leave the machine or are best read in full ─
364    // It leaves the machine: an interface confirms it first.
365    let mut pr = action(
366        "review",
367        "open a draft pull request",
368        "push the branch and its evidence; open (or update) the draft",
369        "speccoder pr --yes",
370    );
371    pr = pr.with_run(Runnable::PullRequest);
372    pr.confirm = Some(
373        "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(),
374    );
375    out.push(pr);
376    out.push(action(
377        "review",
378        "verify the attestations",
379        "every tick against its commit and signature",
380        "speccoder attest verify",
381    ));
382    out.push(action(
383        "review",
384        "check the setup",
385        "git, the project, the config, the model key",
386        "speccoder doctor",
387    ));
388    out
389}
390
391/// `speccoder actions`: what can be done now in the project at `path`
392/// (with `selected` the task an interface has selected) and where the
393/// feature is on its journey — the list every interface renders. Outside
394/// a project: what `report` says there, with an empty list.
395#[must_use]
396pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
397    let report = || speccoder_core::ActionsReport {
398        actions: Vec::new(),
399        journey: journey(path, feature),
400    };
401    if crate::format::at(path).detect(path).is_none() {
402        let status = crate::status::report(path, feature);
403        return CommandResult::carried("actions", &status)
404            .with_data(CommandData::Actions(Box::new(report())));
405    }
406    let mut r = report();
407    r.actions = available(path, feature, selected);
408    let message = match speccoder_core::actions::primary(&r.actions) {
409        Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
410        None => format!("{} action(s)", r.actions.len()),
411    };
412    CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
413}
414
415/// Where the feature is on its journey (spec → plan → tasks → commit → run
416/// → review); the report's, or the start of it.
417#[must_use]
418pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
419    match crate::status::report(path, feature).data() {
420        Some(CommandData::Report(r)) => r.journey.clone(),
421        _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
422    }
423}
424
425/// An interface that takes actions, and so decides which kinds it offers.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum Interface {
428    /// The web host's jobs (`POST /api/run`).
429    WebJobs,
430    /// The line CLI's offer of the next step.
431    CliFollow,
432    /// The line CLI's offer of a fix speccoder knows.
433    CliRemedy,
434    /// An editor's fix, over the Agent Client Protocol.
435    AcpRemedy,
436    /// The dashboard's own keys (its `n` hands off to [`Interface::CliFollow`]).
437    Dashboard,
438}
439
440impl Interface {
441    /// Every interface.
442    pub const ALL: &'static [Self] = &[
443        Self::WebJobs,
444        Self::CliFollow,
445        Self::CliRemedy,
446        Self::AcpRemedy,
447        Self::Dashboard,
448    ];
449
450    /// What it answers for a kind it does not offer.
451    #[must_use]
452    pub fn refusal(self) -> CommandResult {
453        match self {
454            Self::CliFollow => CommandResult::new(
455                "next",
456                CommandStatus::UsageError,
457                "not a step speccoder takes here",
458            ),
459            Self::Dashboard => CommandResult::new(
460                "tui",
461                CommandStatus::UsageError,
462                "not something the dashboard does",
463            ),
464            Self::WebJobs => CommandResult::new(
465                "job",
466                CommandStatus::UsageError,
467                "not something the web app runs",
468            ),
469            Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
470                "fix",
471                CommandStatus::UsageError,
472                "not a fix speccoder applies here",
473            ),
474        }
475    }
476}
477
478/// Whether an interface takes a kind.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum Offer {
481    /// It does.
482    Take,
483    /// It does not: it answers with [`Interface::refusal`], before anything runs.
484    Refuse,
485}
486
487/// Which kinds each interface takes: every pair decided, no default, so a
488/// new kind does not build until each interface has a decision.
489#[must_use]
490pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
491    use ActionKind as K;
492    match interface {
493        Interface::WebJobs => match kind {
494            K::Skill
495            | K::RunTask
496            | K::RunFeature
497            | K::Converge
498            | K::Commit
499            | K::Approve
500            | K::Reject
501            | K::Accept
502            | K::Skip
503            | K::Nudge
504            | K::Done
505            | K::Hint
506            | K::Rule
507            | K::Review
508            | K::IssueComment
509            | K::QuickChange
510            | K::KeepSetAside
511            | K::DropSetAside
512            | K::FixSetup
513            | K::Feature
514            | K::PullRequest
515            | K::BugFix
516            | K::BugConfirm
517            | K::BugRun
518            | K::BugKeep
519            | K::AllowAlways
520            | K::WidenPolicy
521            | K::DecideBatch
522            | K::OracleTests
523            | K::ApproveAndRun
524            | K::Comment
525            | K::CommentsSend
526            | K::RevisionUndo
527            | K::CodeNudge
528            | K::AcceptanceAt
529            | K::PresetAdd
530            | K::PlanThrough
531            | K::NewProject
532            | K::UndoNewProject
533            | K::CloneRepository
534            | K::OpenSample
535            | K::SampleChecks => Offer::Take,
536            K::Command => Offer::Refuse,
537        },
538        // Decisions belong to their own prompt; what leaves the machine
539        // or asks for words is the person's own command there.
540        Interface::CliFollow => match kind {
541            // Plan through's continuation is the `Next:` step after a stop.
542            K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
543                Offer::Take
544            }
545            K::Approve
546            | K::Reject
547            | K::Accept
548            | K::Skip
549            | K::Nudge
550            | K::Done
551            | K::Hint
552            | K::Rule
553            | K::Review
554            | K::IssueComment
555            | K::QuickChange
556            | K::KeepSetAside
557            | K::DropSetAside
558            | K::FixSetup
559            | K::Feature
560            | K::PullRequest
561            | K::BugFix
562            | K::BugConfirm
563            | K::BugRun
564            | K::BugKeep
565            | K::AllowAlways
566            | K::WidenPolicy
567            | K::DecideBatch
568            | K::OracleTests
569            | K::ApproveAndRun
570            | K::Comment
571            | K::CommentsSend
572            | K::RevisionUndo
573            | K::CodeNudge
574            | K::AcceptanceAt
575            | K::PresetAdd
576            | K::NewProject
577            | K::UndoNewProject
578            | K::CloneRepository
579            | K::OpenSample
580            | K::SampleChecks
581            | K::Command => Offer::Refuse,
582        },
583        // Its keys decide the queue's calls and switch the feature; `n`
584        // hands the next step to the line CLI's offer.
585        Interface::Dashboard => match kind {
586            K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
587            K::Skill
588            | K::RunTask
589            | K::RunFeature
590            | K::Converge
591            | K::Commit
592            | K::Accept
593            | K::Skip
594            | K::Nudge
595            | K::Done
596            | K::Hint
597            | K::Rule
598            | K::Review
599            | K::IssueComment
600            | K::QuickChange
601            | K::KeepSetAside
602            | K::DropSetAside
603            | K::FixSetup
604            | K::PullRequest
605            | K::BugFix
606            | K::BugConfirm
607            | K::BugRun
608            | K::BugKeep
609            | K::WidenPolicy
610            | K::OracleTests
611            | K::ApproveAndRun
612            | K::Comment
613            | K::CommentsSend
614            | K::RevisionUndo
615            | K::CodeNudge
616            | K::AcceptanceAt
617            | K::PresetAdd
618            | K::PlanThrough
619            | K::NewProject
620            | K::UndoNewProject
621            | K::CloneRepository
622            | K::OpenSample
623            | K::SampleChecks
624            | K::Command => Offer::Refuse,
625        },
626        Interface::CliRemedy | Interface::AcpRemedy => match kind {
627            K::FixSetup => Offer::Take,
628            K::Skill
629            | K::RunTask
630            | K::RunFeature
631            | K::Converge
632            | K::Commit
633            | K::Approve
634            | K::Reject
635            | K::Accept
636            | K::Skip
637            | K::Nudge
638            | K::Done
639            | K::Hint
640            | K::Rule
641            | K::Review
642            | K::IssueComment
643            | K::QuickChange
644            | K::KeepSetAside
645            | K::DropSetAside
646            | K::Feature
647            | K::PullRequest
648            | K::BugFix
649            | K::BugConfirm
650            | K::BugRun
651            | K::BugKeep
652            | K::AllowAlways
653            | K::WidenPolicy
654            | K::DecideBatch
655            | K::OracleTests
656            | K::ApproveAndRun
657            | K::Comment
658            | K::CommentsSend
659            | K::RevisionUndo
660            | K::CodeNudge
661            | K::AcceptanceAt
662            | K::PresetAdd
663            | K::PlanThrough
664            | K::NewProject
665            | K::UndoNewProject
666            | K::CloneRepository
667            | K::OpenSample
668            | K::SampleChecks
669            | K::Command => Offer::Refuse,
670        },
671    }
672}
673
674/// An action to take: what runs, the person's input, the interface it
675/// came through and how that interface runs it (who it asks, where
676/// progress goes, its cancel flag).
677#[derive(Debug)]
678pub struct Dispatch<'a> {
679    /// What runs.
680    pub run: &'a Runnable,
681    /// The person's input (a reason, a note, a skill's arguments); blank
682    /// is none.
683    pub input: Option<&'a str>,
684    /// The interface, as what the person decides is recorded.
685    pub via: Via,
686    /// The interface's approver, progress and cancellation.
687    pub options: RunOptions,
688}
689
690impl<'a> Dispatch<'a> {
691    /// `run` through `via`, without input, asking nobody.
692    #[must_use]
693    pub fn new(run: &'a Runnable, via: Via) -> Self {
694        Self {
695            run,
696            input: None,
697            via,
698            options: RunOptions::default(),
699        }
700    }
701}
702
703/// [`perform`], if `interface` offers the action's kind; `None`, with
704/// nothing run, if it does not.
705#[must_use]
706pub fn perform_for(
707    interface: Interface,
708    root: &Path,
709    feature: Option<&str>,
710    d: Dispatch<'_>,
711) -> Option<CommandResult> {
712    (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
713}
714
715/// Takes the action: the only place a [`Runnable`] becomes runtime calls.
716/// It never prompts and never confirms (an interface has, before); what
717/// an interface does not offer it refuses before calling this
718/// ([`offered`]).
719#[must_use]
720pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
721    perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
722}
723
724/// `options` with this run's agent, when the action names one.
725fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
726    RunOptions {
727        agent: agent.cloned().or_else(|| options.agent.clone()),
728        ..options.clone()
729    }
730}
731
732/// [`perform`] with the state directory given (`None`: there is none).
733#[must_use]
734pub fn perform_in(
735    root: &Path,
736    feature: Option<&str>,
737    d: Dispatch<'_>,
738    state: Option<&Path>,
739) -> CommandResult {
740    use crate::approvals::{Choice, Verb};
741    let needs = |what: &str| {
742        CommandResult::new(
743            "job",
744            CommandStatus::UsageError,
745            &format!("{what} is needed"),
746        )
747    };
748    let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
749    let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
750    let options = &d.options;
751    let via = d.via;
752    let text = d.input.map(str::trim).filter(|t| !t.is_empty());
753    match d.run {
754        Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
755        Runnable::Run {
756            task: Some(t),
757            agent,
758        } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
759        Runnable::Run { task: None, agent } => {
760            run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
761        }
762        Runnable::Converge => run_feature(root, feature, options, true),
763        Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
764        Runnable::Approve { task, call, digest } => decide(Verb::Decide(
765            Choice::approve(task, text).shown(call, digest),
766        )),
767        Runnable::Reject { task, call, digest } => match text {
768            Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
769            None => needs("a reason"),
770        },
771        Runnable::Accept { task } => match (text, state) {
772            (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
773            (None, _) => needs("a reason"),
774            (_, None) => no_state(),
775        },
776        // A decision recorded in the state directory, never the spec.
777        Runnable::AcceptanceAt {
778            feature: unit,
779            story,
780            task,
781            revoke,
782        } => match state {
783            Some(s) => {
784                crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
785            }
786            None => no_state(),
787        },
788        // The interface has confirmed: Spec Kit's own command runs.
789        Runnable::PresetAdd { update } => match state {
790            Some(s) => crate::preset::add(root, *update, true, s),
791            None => no_state(),
792        },
793        Runnable::Skip { task } => match text {
794            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
795            None => needs("a reason"),
796        },
797        Runnable::Nudge { task } => match (text, state) {
798            (Some(note), Some(s)) => {
799                crate::controls::nudge_with(root, feature, task, Some(note), s)
800            }
801            (None, _) => needs("a note"),
802            (_, None) => no_state(),
803        },
804        // I did it: the person's word, in the words they gave or these.
805        // In place when the dispatch is (an answer at an in-place run's stop).
806        Runnable::Done { task } => match state {
807            Some(s) => crate::controls::accept_in_session(
808                root,
809                feature,
810                task,
811                text.unwrap_or(DONE_REASON),
812                s,
813                options,
814            ),
815            None => no_state(),
816        },
817        // Hint & retry: the note first; a new attempt only once it is kept.
818        Runnable::Hint { task } => match (text, state) {
819            (Some(note), Some(s)) => {
820                let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
821                if noted.status() == CommandStatus::Ok {
822                    run_task(root, feature, task, options)
823                } else {
824                    noted
825                }
826            }
827            (None, _) => needs("a hint"),
828            (_, None) => no_state(),
829        },
830        Runnable::Rule { task } => match (text, state) {
831            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
832            (None, _) => needs("a reason"),
833            (_, None) => no_state(),
834        },
835        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
836            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
837            ("edit" | "edit-approve", Some(id), Some(t)) => {
838                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
839            }
840            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
841            _ => crate::review::decide(root, feature, ids, verdict, text, via),
842        },
843        Runnable::IssueComment { issue, assign } => crate::github::comment(
844            &crate::github::Ctx::from_env(),
845            root,
846            feature,
847            &issue.to_string(),
848            *assign,
849        ),
850        Runnable::QuickChange {
851            request,
852            checks,
853            run,
854        } => quick_change(root, request, checks, *run, via, options),
855        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
856        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
857        Runnable::FixSetup => crate::code::fix_setup(root, feature),
858        Runnable::Feature => match text {
859            Some(name) => crate::current::switch(root, name),
860            None => needs("a feature"),
861        },
862        Runnable::BugFix {
863            symptom,
864            expected,
865            unchanged,
866            issue,
867            tests,
868            candidate,
869            slug,
870            run,
871        } => bug_fix(
872            root,
873            &crate::bug::Request::of(
874                symptom,
875                expected,
876                unchanged,
877                *issue,
878                tests,
879                candidate.as_deref(),
880                slug.as_deref(),
881                *run,
882            ),
883            via,
884            state,
885            options,
886        ),
887        Runnable::BugConfirm {
888            task,
889            digest,
890            reject,
891        } => match (*reject, text) {
892            (true, None) => needs("a reason"),
893            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
894            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
895        },
896        Runnable::BugRun { slug, task } => {
897            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
898        }
899        Runnable::BugKeep { task } => match text {
900            Some(why) => crate::bug::keep(root, task, why, state),
901            None => needs("a reason"),
902        },
903        Runnable::AllowAlways { task, call } => {
904            crate::approvals::allow_always(root, feature, task, call, state, via)
905        }
906        Runnable::WidenPolicy {
907            key,
908            prefix,
909            digest,
910        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
911        Runnable::DecideBatch {
912            run,
913            verdict,
914            calls,
915            digest,
916            resume_asking,
917        } => {
918            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
919            if !approve && text.is_none() {
920                return needs("a reason");
921            }
922            crate::approvals::decide_batch(
923                root,
924                feature,
925                crate::approvals::BatchChoice {
926                    run,
927                    approve,
928                    calls,
929                    digest,
930                    resume_asking: *resume_asking,
931                    reason: text,
932                },
933                state,
934                via,
935            )
936        }
937        Runnable::OracleTests {
938            req,
939            op,
940            path,
941            scenario,
942            reading,
943        } => crate::oracle::tests(
944            root,
945            feature,
946            &crate::oracle::Request {
947                req,
948                op: *op,
949                path: path.as_deref(),
950                scenario: scenario.as_deref(),
951                reading: *reading,
952                text: d.input.filter(|t| !t.trim().is_empty()),
953            },
954            via,
955            state,
956            options,
957        ),
958        Runnable::ApproveAndRun {
959            mode,
960            grants,
961            sheet,
962        } => crate::review::approve_and_run(
963            root,
964            feature,
965            *mode,
966            grants,
967            sheet.as_deref(),
968            via,
969            state,
970            None,
971            options,
972        ),
973        Runnable::Comment { op, anchor, id } => crate::comments::comment(
974            root,
975            feature,
976            *op,
977            anchor.as_ref(),
978            id.as_deref(),
979            text,
980            via,
981            state,
982        ),
983        Runnable::CommentsSend { ids } => {
984            crate::comments::send(root, feature, ids, via, state, options)
985        }
986        Runnable::RevisionUndo { turn } => {
987            crate::comments::undo(root, feature, turn.as_deref(), state)
988        }
989        Runnable::CodeNudge { task, comments } => {
990            crate::comments::code_nudge(root, feature, task, comments, state)
991        }
992        // The format's own skills, in its order; the stops are read from
993        // the unit's files, so nothing of the chain is kept here.
994        Runnable::PlanThrough {
995            from:
996                speccoder_core::actions::PlanFrom::Create {
997                    description,
998                    source,
999                },
1000            through,
1001        } => {
1002            let description = if description.trim().is_empty() {
1003                text.unwrap_or_default()
1004            } else {
1005                description.as_str()
1006            };
1007            plan_through(root, description, source, *through, options)
1008        }
1009        Runnable::PlanThrough {
1010            from: speccoder_core::actions::PlanFrom::Continue,
1011            through,
1012        } => crate::planning_chain::resume(root, feature, *through, options),
1013        // The plan the person confirmed, recomputed and compared first.
1014        Runnable::NewProject {
1015            target,
1016            choices,
1017            plan_digest,
1018        } => match state {
1019            Some(s) => crate::new_project::perform(
1020                Path::new(target),
1021                choices,
1022                plan_digest,
1023                via,
1024                s,
1025                options,
1026            ),
1027            None => no_state(),
1028        },
1029        Runnable::UndoNewProject { commit } => match state {
1030            Some(s) => crate::new_project::undo(root, commit, via, s, options),
1031            None => no_state(),
1032        },
1033        // The interface has confirmed it names github.com.
1034        Runnable::CloneRepository { repo, target } => {
1035            crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1036        }
1037        Runnable::OpenSample { fresh } => match state {
1038            Some(s) => crate::sample::open(s, *fresh),
1039            None => no_state(),
1040        },
1041        Runnable::SampleChecks => match state {
1042            Some(s) => crate::sample::run_checks(s, root),
1043            None => no_state(),
1044        },
1045        // The interface has confirmed it (the action's `confirm`): push
1046        // and open.
1047        Runnable::PullRequest => crate::handoff::pr(
1048            root,
1049            feature,
1050            &crate::handoff::PrOptions {
1051                remote: "origin",
1052                base: None,
1053                open: true,
1054                force: false,
1055                github: &crate::github::Ctx::from_env(),
1056            },
1057        ),
1058    }
1059}
1060
1061/// The calls a person answered Always Allow inline: each one's exact argv
1062/// joins the allow list through [`perform`] with the interface it was
1063/// answered through. What each did, as notices.
1064#[must_use]
1065pub fn allow_always_inline(
1066    root: &Path,
1067    feature: &str,
1068    always: &[crate::substrate::Always],
1069    state: &Path,
1070) -> Vec<String> {
1071    always
1072        .iter()
1073        .map(|a| {
1074            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1075            let run = Runnable::AllowAlways {
1076                task,
1077                call: a.call.clone(),
1078            };
1079            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1080                .message()
1081                .to_owned()
1082        })
1083        .collect()
1084}
1085
1086/// The person's answer at a stop, given inline at their terminal, taken
1087/// through [`perform`] with the interface it was answered through: I did
1088/// it (`Done`, in the run's in-place session when it has one), the hint
1089/// (`Nudge`: the run then attempts the task again, with its own model) or
1090/// skip (`Skip`).
1091#[must_use]
1092pub fn answered_at_stop(
1093    root: &Path,
1094    feature: Option<&str>,
1095    task: &str,
1096    answer: &crate::approvals::StopAnswer,
1097    (via, run): (Via, &RunOptions),
1098    state: &Path,
1099) -> CommandResult {
1100    use crate::approvals::StopAnswer;
1101    let task = task.to_owned();
1102    let (what, input) = match answer {
1103        StopAnswer::Done => (Runnable::Done { task }, None),
1104        StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1105        StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1106    };
1107    perform_in(
1108        root,
1109        feature,
1110        Dispatch {
1111            input,
1112            options: RunOptions {
1113                in_place: run.in_place,
1114                session: run.session.clone(),
1115                ..RunOptions::default()
1116            },
1117            ..Dispatch::new(&what, via)
1118        },
1119        Some(state),
1120    )
1121}
1122
1123// The arms that run the model, one function each, so a build without the
1124// engine can leave them out without a second dispatcher.
1125
1126/// A planning skill: the person's input, else the arguments it carries.
1127fn skill(
1128    root: &Path,
1129    feature: Option<&str>,
1130    name: &str,
1131    input: Option<&str>,
1132    carried: &str,
1133    options: &RunOptions,
1134) -> CommandResult {
1135    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1136}
1137
1138fn plan_through(
1139    root: &Path,
1140    description: &str,
1141    source: &speccoder_core::actions::FeatureSource,
1142    through: speccoder_core::actions::Through,
1143    options: &RunOptions,
1144) -> CommandResult {
1145    crate::planning_chain::start(root, description, source, through, options)
1146}
1147
1148fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1149    crate::run(root, feature, task, options)
1150}
1151
1152fn run_feature(
1153    root: &Path,
1154    feature: Option<&str>,
1155    options: &RunOptions,
1156    converge: bool,
1157) -> CommandResult {
1158    crate::run_feature(root, feature, options, converge)
1159}
1160
1161fn bug_fix(
1162    root: &Path,
1163    req: &crate::bug::Request,
1164    via: Via,
1165    state: Option<&Path>,
1166    options: &RunOptions,
1167) -> CommandResult {
1168    crate::bug::bug(root, req, via, state, options)
1169}
1170
1171fn bug_confirm(
1172    root: &Path,
1173    task: &str,
1174    digest: &str,
1175    reject: Option<&str>,
1176    via: Via,
1177    state: Option<&Path>,
1178    options: &RunOptions,
1179) -> CommandResult {
1180    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1181}
1182
1183fn bug_run(
1184    root: &Path,
1185    slug: Option<&str>,
1186    task: Option<&str>,
1187    state: Option<&Path>,
1188    options: &RunOptions,
1189) -> CommandResult {
1190    crate::bug::run(root, slug, task, state, options)
1191}
1192
1193fn quick_change(
1194    root: &Path,
1195    request: &str,
1196    checks: &[String],
1197    run: bool,
1198    via: Via,
1199    options: &RunOptions,
1200) -> CommandResult {
1201    crate::quick::change(root, request, checks, run, via, options)
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use speccoder_core::actions::primary;
1207
1208    use super::*;
1209
1210    #[test]
1211    fn every_interface_decides_every_kind() {
1212        let mut taken = 0;
1213        for i in Interface::ALL {
1214            for k in ActionKind::ALL {
1215                if offered(*i, *k) == Offer::Take {
1216                    taken += 1;
1217                } else {
1218                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1219                }
1220            }
1221        }
1222        // The web host runs every runnable kind; the terminal the steps
1223        // it offers (plan through's continuation among them); the remedies
1224        // only the setup's fix.
1225        let runnable = Runnable::examples().len();
1226        // The dashboard also decides Always Allow and batches.
1227        assert_eq!(taken, runnable + 6 + 5 + 2);
1228        assert_eq!(
1229            offered(Interface::CliFollow, ActionKind::PullRequest),
1230            Offer::Refuse
1231        );
1232        assert_eq!(
1233            offered(Interface::AcpRemedy, ActionKind::FixSetup),
1234            Offer::Take
1235        );
1236    }
1237
1238    /// The speccoder commands no interface takes: developer tools, and a
1239    /// pull request to a remote only the person can name.
1240    fn copied(line: &str) -> bool {
1241        let mut w = line.split_whitespace().skip(1);
1242        matches!(w.next(), Some("eval" | "replay" | "bench"))
1243            || line.contains("--remote <")
1244            // Another project's command: run there.
1245            || line.split_whitespace().any(|w| w == "-C")
1246            // A line about a command's own flags (`{command} --help`): said.
1247            || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1248                && line.split_whitespace().count() == 3
1249    }
1250
1251    /// String literals that start `speccoder ` and are a message's prose,
1252    /// not a command (the start of each). A new one is added here only
1253    /// when it is no command.
1254    const PROSE: &[&str] = &[
1255        "speccoder did not ",
1256        "speccoder cannot ",
1257        "speccoder commits ",
1258        "speccoder could ",
1259        "speccoder failed ",
1260        "speccoder has ",
1261        "speccoder is ",
1262        "speccoder needs ",
1263        "speccoder reads ",
1264        "speccoder works ",
1265        "speccoder does not ",
1266        "speccoder implements ",
1267        "speccoder converges ",
1268        "speccoder runs ",
1269        "speccoder Documentation",
1270        "speccoder — ",
1271        "speccoder ✓",
1272        "speccoder ■",
1273        "speccoder ▸",
1274        "speccoder △",
1275        "speccoder ✗",
1276        "speccoder / ",
1277        "speccoder attempt: ",
1278        "speccoder manifest: ",
1279        "speccoder inspect: ",
1280        "speccoder preset ",
1281        "speccoder bundles",
1282        "speccoder {}: ",
1283        "speccoder {feature}: ",
1284        "speccoder 001-x: ",
1285        "speccoder {} · ",
1286        // The command the person typed, echoed.
1287        "speccoder {} --yes",
1288    ];
1289
1290    /// Every `speccoder …` command the catalog and every builder print
1291    /// (each string literal of the core, runtime, web and CLI sources,
1292    /// placeholders filled) is performed, opened as a view or run as one
1293    /// of speccoder's own commands — never only copied, and never words
1294    /// the parser could not read.
1295    #[test]
1296    fn no_speccoder_command_is_copy_only() {
1297        let ctx = Context {
1298            feature: Some("001-x"),
1299            task: Some("T001"),
1300        };
1301        let mut seen = 0;
1302        let mut errors = Vec::new();
1303        for e in speccoder_core::codes::CATALOG {
1304            for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1305                if line.starts_with("speccoder ") && !copied(&line) {
1306                    let how = runnable_of(&line, ctx).how;
1307                    if how.copy_only() {
1308                        errors.push(format!("{}: {line} → {how:?}", e.name));
1309                    }
1310                    seen += 1;
1311                }
1312            }
1313        }
1314        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1315        let mut files = Vec::new();
1316        for dir in [
1317            "src",
1318            "../speccoder-core/src",
1319            "../speccoder-web/src",
1320            "../speccoder/src",
1321        ] {
1322            let mut stack = vec![root.join(dir)];
1323            while let Some(d) = stack.pop() {
1324                for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1325                    let p = entry.path();
1326                    if p.is_dir() {
1327                        stack.push(p);
1328                    } else if p.extension().is_some_and(|x| x == "rs") {
1329                        files.push(p);
1330                    }
1331                }
1332            }
1333        }
1334        assert!(files.len() > 100, "{} files", files.len());
1335        for f in files {
1336            let src = std::fs::read_to_string(&f).unwrap_or_default();
1337            // A test's lines are its cases, not what speccoder prints.
1338            let src = src
1339                .split("#[cfg(test)]\nmod tests")
1340                .next()
1341                .unwrap_or_default();
1342            for (at, _) in src.match_indices("\"speccoder ") {
1343                // A quote within another literal is no literal.
1344                if src[..at].ends_with('\\') {
1345                    continue;
1346                }
1347                let mut line = String::new();
1348                let mut chars = src[at + 1..].chars();
1349                while let Some(c) = chars.next() {
1350                    match c {
1351                        '\\' => line.extend(chars.next()),
1352                        '"' => break,
1353                        c => line.push(c),
1354                    }
1355                }
1356                // A format string's arguments, filled.
1357                let mut filled = String::new();
1358                let mut rest = line.as_str();
1359                while let Some(open) = rest.find('{') {
1360                    filled.push_str(&rest[..open]);
1361                    let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1362                    // A suffix (`doctor{f}`: ` --feature F` or nothing).
1363                    let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1364                    filled.push_str(match &rest[open..close] {
1365                        _ if suffix => "",
1366                        "{task}" | "{t}" => "T001",
1367                        "{feature}" | "{f}" => "001-x",
1368                        "{command}" | "{verb}" => "run",
1369                        _ if filled.ends_with("--through ") => "tasks",
1370                        _ => "x",
1371                    });
1372                    rest = &rest[close.min(rest.len())..];
1373                }
1374                filled.push_str(rest);
1375                // A prefix compared against, no line.
1376                if line == "speccoder "
1377                    || copied(&filled)
1378                    || PROSE.iter().any(|p| line.starts_with(p))
1379                {
1380                    continue;
1381                }
1382                let how = runnable_of(&filled, ctx).how;
1383                if how.copy_only() {
1384                    errors.push(format!("{}: {filled} → {how:?}", f.display()));
1385                }
1386                seen += 1;
1387            }
1388        }
1389        assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1390        assert!(seen > 100, "{seen} lines seen");
1391    }
1392
1393    #[test]
1394    fn blank_input_is_no_input() {
1395        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1396        let run = Runnable::Reject {
1397            task: "T001".to_owned(),
1398            call: "attempt:T001".to_owned(),
1399            digest: String::new(),
1400        };
1401        let r = perform_in(
1402            dir.path(),
1403            None,
1404            Dispatch {
1405                run: &run,
1406                input: Some("  "),
1407                via: Via::Web,
1408                options: RunOptions::default(),
1409            },
1410            None,
1411        );
1412        assert_eq!(
1413            (r.command(), r.status(), r.message()),
1414            ("job", CommandStatus::UsageError, "a reason is needed")
1415        );
1416    }
1417
1418    #[test]
1419    fn a_project_outside_git_still_gets_a_list() {
1420        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1421        let list = available(dir.path(), None, None);
1422        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1423        assert!(primary(&list).is_none());
1424    }
1425}