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