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