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::HookTrust
613            | K::HookTest
614            | K::ExploreChoose => Offer::Take,
615            K::Command => Offer::Refuse,
616        },
617        // Decisions belong to their own prompt; what leaves the machine
618        // or asks for words is the person's own command there.
619        Interface::CliFollow => match kind {
620            // Plan through's continuation is the `Next:` step after a stop.
621            K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
622                Offer::Take
623            }
624            K::Approve
625            | K::Reject
626            | K::Accept
627            | K::Skip
628            | K::Nudge
629            | K::Done
630            | K::Hint
631            | K::Rule
632            | K::Review
633            | K::IssueComment
634            | K::QuickChange
635            | K::KeepSetAside
636            | K::DropSetAside
637            | K::FixSetup
638            | K::Feature
639            | K::PullRequest
640            | K::BugFix
641            | K::BugConfirm
642            | K::BugRun
643            | K::BugKeep
644            | K::AllowAlways
645            | K::WidenPolicy
646            | K::DecideBatch
647            | K::OracleTests
648            | K::CheckStory
649            | K::ApproveAndRun
650            | K::Comment
651            | K::CommentsSend
652            | K::RevisionUndo
653            | K::CodeNudge
654            | K::AcceptanceAt
655            | K::RuleScenario
656            | K::AcceptUntestable
657            | K::PresetAdd
658            | K::NewProject
659            | K::UndoNewProject
660            | K::CloneRepository
661            | K::OpenSample
662            | K::SampleChecks
663            | K::DeclinePromotion
664            | K::Explore
665            | K::ExploreChoose
666            | K::HookTrust
667            | K::HookTest
668            | K::Command => Offer::Refuse,
669        },
670        // What a proposal may be: a quick change, a bug fix, a nudge, an
671        // answer to an open question, a new spec. Nothing else.
672        Interface::AskProposal => match kind {
673            K::QuickChange | K::BugFix | K::Nudge | K::Review | K::PlanThrough => Offer::Take,
674            K::Skill
675            | K::RunTask
676            | K::RunFeature
677            | K::Converge
678            | K::Commit
679            | K::Approve
680            | K::Reject
681            | K::Accept
682            | K::Skip
683            | K::Done
684            | K::Hint
685            | K::Rule
686            | K::IssueComment
687            | K::KeepSetAside
688            | K::DropSetAside
689            | K::FixSetup
690            | K::Feature
691            | K::PullRequest
692            | K::BugConfirm
693            | K::BugRun
694            | K::BugKeep
695            | K::AllowAlways
696            | K::WidenPolicy
697            | K::DecideBatch
698            | K::OracleTests
699            | K::CheckStory
700            | K::ApproveAndRun
701            | K::Comment
702            | K::CommentsSend
703            | K::RevisionUndo
704            | K::CodeNudge
705            | K::AcceptanceAt
706            | K::RuleScenario
707            | K::AcceptUntestable
708            | K::PresetAdd
709            | K::NewProject
710            | K::UndoNewProject
711            | K::CloneRepository
712            | K::OpenSample
713            | K::SampleChecks
714            | K::DeclinePromotion
715            | K::Explore
716            | K::ExploreChoose
717            | K::HookTrust
718            | K::HookTest
719            | K::Command => Offer::Refuse,
720        },
721        // Its keys decide the queue's calls and switch the feature; `n`
722        // hands the next step to the line CLI's offer.
723        Interface::Dashboard => match kind {
724            K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
725            K::Skill
726            | K::RunTask
727            | K::RunFeature
728            | K::Converge
729            | K::Commit
730            | K::Accept
731            | K::Skip
732            | K::Nudge
733            | K::Done
734            | K::Hint
735            | K::Rule
736            | K::Review
737            | K::IssueComment
738            | K::QuickChange
739            | K::KeepSetAside
740            | K::DropSetAside
741            | K::FixSetup
742            | K::PullRequest
743            | K::BugFix
744            | K::BugConfirm
745            | K::BugRun
746            | K::BugKeep
747            | K::WidenPolicy
748            | K::OracleTests
749            | K::CheckStory
750            | K::ApproveAndRun
751            | K::Comment
752            | K::CommentsSend
753            | K::RevisionUndo
754            | K::CodeNudge
755            | K::AcceptanceAt
756            | K::RuleScenario
757            | K::AcceptUntestable
758            | K::PresetAdd
759            | K::PlanThrough
760            | K::NewProject
761            | K::UndoNewProject
762            | K::CloneRepository
763            | K::OpenSample
764            | K::SampleChecks
765            | K::DeclinePromotion
766            | K::Explore
767            | K::ExploreChoose
768            | K::HookTrust
769            | K::HookTest
770            | K::Command => Offer::Refuse,
771        },
772        Interface::CliRemedy | Interface::AcpRemedy => match kind {
773            K::FixSetup => Offer::Take,
774            K::Skill
775            | K::RunTask
776            | K::RunFeature
777            | K::Converge
778            | K::Commit
779            | K::Approve
780            | K::Reject
781            | K::Accept
782            | K::Skip
783            | K::Nudge
784            | K::Done
785            | K::Hint
786            | K::Rule
787            | K::Review
788            | K::IssueComment
789            | K::QuickChange
790            | K::KeepSetAside
791            | K::DropSetAside
792            | K::Feature
793            | K::PullRequest
794            | K::BugFix
795            | K::BugConfirm
796            | K::BugRun
797            | K::BugKeep
798            | K::AllowAlways
799            | K::WidenPolicy
800            | K::DecideBatch
801            | K::OracleTests
802            | K::CheckStory
803            | K::ApproveAndRun
804            | K::Comment
805            | K::CommentsSend
806            | K::RevisionUndo
807            | K::CodeNudge
808            | K::AcceptanceAt
809            | K::RuleScenario
810            | K::AcceptUntestable
811            | K::PresetAdd
812            | K::PlanThrough
813            | K::NewProject
814            | K::UndoNewProject
815            | K::CloneRepository
816            | K::OpenSample
817            | K::SampleChecks
818            | K::DeclinePromotion
819            | K::Explore
820            | K::ExploreChoose
821            | K::HookTrust
822            | K::HookTest
823            | K::Command => Offer::Refuse,
824        },
825    }
826}
827
828/// An action to take: what runs, the person's input, the interface it
829/// came through and how that interface runs it (who it asks, where
830/// progress goes, its cancel flag).
831#[derive(Debug)]
832pub struct Dispatch<'a> {
833    /// What runs.
834    pub run: &'a Runnable,
835    /// The person's input (a reason, a note, a skill's arguments); blank
836    /// is none.
837    pub input: Option<&'a str>,
838    /// The interface, as what the person decides is recorded.
839    pub via: Via,
840    /// The interface's approver, progress and cancellation.
841    pub options: RunOptions,
842}
843
844impl<'a> Dispatch<'a> {
845    /// `run` through `via`, without input, asking nobody.
846    #[must_use]
847    pub fn new(run: &'a Runnable, via: Via) -> Self {
848        Self {
849            run,
850            input: None,
851            via,
852            options: RunOptions::default(),
853        }
854    }
855}
856
857/// [`perform`], if `interface` offers the action's kind; `None`, with
858/// nothing run, if it does not.
859#[must_use]
860pub fn perform_for(
861    interface: Interface,
862    root: &Path,
863    feature: Option<&str>,
864    d: Dispatch<'_>,
865) -> Option<CommandResult> {
866    (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
867}
868
869/// Takes the action: the only place a [`Runnable`] becomes runtime calls.
870/// It never prompts and never confirms (an interface has, before); what
871/// an interface does not offer it refuses before calling this
872/// ([`offered`]).
873#[must_use]
874pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
875    perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
876}
877
878/// `options` with this run's agent, when the action names one.
879/// `options` with a run's own agent and cap, when the command names them.
880fn with_agent(options: &RunOptions, agent: Option<&String>, cap: Option<&String>) -> RunOptions {
881    RunOptions {
882        agent: agent.cloned().or_else(|| options.agent.clone()),
883        cap: cap
884            .and_then(|c| crate::substrate::run_config::Cap::parse(c).ok())
885            .or(options.cap),
886        ..options.clone()
887    }
888}
889
890/// [`perform`] with the state directory given (`None`: there is none).
891#[must_use]
892pub fn perform_in(
893    root: &Path,
894    feature: Option<&str>,
895    d: Dispatch<'_>,
896    state: Option<&Path>,
897) -> CommandResult {
898    use crate::approvals::{Choice, Verb};
899    let needs = |what: &str| {
900        CommandResult::new(
901            "job",
902            CommandStatus::UsageError,
903            &format!("{what} is needed"),
904        )
905    };
906    let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
907    let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
908    let options = &d.options;
909    let via = d.via;
910    let text = d.input.map(str::trim).filter(|t| !t.is_empty());
911    match d.run {
912        Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
913        Runnable::Run {
914            task: Some(t),
915            agent,
916            cap,
917        } => run_task(
918            root,
919            feature,
920            t,
921            &with_agent(options, agent.as_ref(), cap.as_ref()),
922        ),
923        Runnable::Run {
924            task: None,
925            agent,
926            cap,
927        } => run_feature(
928            root,
929            feature,
930            &with_agent(options, agent.as_ref(), cap.as_ref()),
931            false,
932        ),
933        Runnable::Converge => run_feature(root, feature, options, true),
934        Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
935        Runnable::Approve { task, call, digest } => decide(Verb::Decide(
936            Choice::approve(task, text).shown(call, digest),
937        )),
938        Runnable::Reject { task, call, digest } => match text {
939            Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
940            None => needs("a reason"),
941        },
942        Runnable::Accept { task } => match (text, state) {
943            (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
944            (None, _) => needs("a reason"),
945            (_, None) => no_state(),
946        },
947        // A decision recorded in the state directory, never the spec.
948        Runnable::AcceptanceAt {
949            feature: unit,
950            story,
951            task,
952            revoke,
953        } => match state {
954            Some(s) => {
955                crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
956            }
957            None => no_state(),
958        },
959        Runnable::RuleScenario { task, scenario } => match (text, state) {
960            (Some(why), Some(s)) => crate::acceptance_at::rule_tests_wrong(
961                root,
962                feature,
963                task,
964                scenario.as_deref(),
965                why,
966                via,
967                s,
968            ),
969            (None, _) => needs("a reason"),
970            (_, None) => no_state(),
971        },
972        Runnable::AcceptUntestable { scenario } => match (text, state) {
973            (Some(why), Some(s)) => {
974                crate::acceptance_at::accept_untestable(root, feature, scenario, why, via, s)
975            }
976            (None, _) => needs("a reason"),
977            (_, None) => no_state(),
978        },
979        // The interface has confirmed: Spec Kit's own command runs.
980        Runnable::PresetAdd { update } => match state {
981            Some(s) => crate::preset::add(root, *update, true, s),
982            None => no_state(),
983        },
984        Runnable::Skip { task } => match text {
985            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
986            None => needs("a reason"),
987        },
988        Runnable::Nudge { task } => match (text, state) {
989            (Some(note), Some(s)) => {
990                crate::controls::nudge_with(root, feature, task, Some(note), s)
991            }
992            (None, _) => needs("a note"),
993            (_, None) => no_state(),
994        },
995        // I did it: the person's word, in the words they gave or these.
996        // In place when the dispatch is (an answer at an in-place run's stop).
997        Runnable::Done { task } => match state {
998            Some(s) => crate::controls::accept_in_session(
999                root,
1000                feature,
1001                task,
1002                text.unwrap_or(DONE_REASON),
1003                s,
1004                options,
1005            ),
1006            None => no_state(),
1007        },
1008        // Hint & retry: the note first; a new attempt only once it is kept.
1009        Runnable::Hint { task } => match (text, state) {
1010            (Some(note), Some(s)) => {
1011                let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
1012                if noted.status() == CommandStatus::Ok {
1013                    run_task(root, feature, task, options)
1014                } else {
1015                    noted
1016                }
1017            }
1018            (None, _) => needs("a hint"),
1019            (_, None) => no_state(),
1020        },
1021        Runnable::Rule { task } => match (text, state) {
1022            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
1023            (None, _) => needs("a reason"),
1024            (_, None) => no_state(),
1025        },
1026        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
1027            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
1028            ("edit" | "edit-approve", Some(id), Some(t)) => {
1029                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
1030            }
1031            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
1032            _ => crate::review::decide(root, feature, ids, verdict, text, via),
1033        },
1034        Runnable::IssueComment { issue, assign } => crate::github::comment(
1035            &crate::github::Ctx::from_env(),
1036            root,
1037            feature,
1038            &issue.to_string(),
1039            *assign,
1040        ),
1041        Runnable::QuickChange {
1042            request,
1043            checks,
1044            run,
1045            asked,
1046        } => quick_change(
1047            root,
1048            request,
1049            checks,
1050            *run,
1051            via,
1052            options,
1053            asked.as_deref().zip(state),
1054        ),
1055        Runnable::HookTrust { name, digest } => match state {
1056            Some(s) => crate::hooks::report::trust_shown(root, s, name, digest, via.as_str()),
1057            None => no_state(),
1058        },
1059        Runnable::HookTest { name } => match state {
1060            Some(s) => crate::hooks::report::test(root, s, name),
1061            None => no_state(),
1062        },
1063        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
1064        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
1065        Runnable::FixSetup => crate::code::fix_setup(root, feature),
1066        Runnable::Feature => match text {
1067            Some(name) => crate::current::switch(root, name),
1068            None => needs("a feature"),
1069        },
1070        Runnable::BugFix {
1071            symptom,
1072            expected,
1073            unchanged,
1074            issue,
1075            tests,
1076            candidate,
1077            slug,
1078            run,
1079            asked,
1080        } => bug_fix(
1081            root,
1082            &crate::bug::Request {
1083                asked: asked.clone(),
1084                ..crate::bug::Request::of(
1085                    symptom,
1086                    expected,
1087                    unchanged,
1088                    *issue,
1089                    tests,
1090                    candidate.as_deref(),
1091                    slug.as_deref(),
1092                    *run,
1093                )
1094            },
1095            via,
1096            state,
1097            options,
1098        ),
1099        Runnable::BugConfirm {
1100            task,
1101            digest,
1102            reject,
1103        } => match (*reject, text) {
1104            (true, None) => needs("a reason"),
1105            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1106            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1107        },
1108        Runnable::BugRun { slug, task } => {
1109            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1110        }
1111        Runnable::BugKeep { task } => match text {
1112            Some(why) => crate::bug::keep(root, task, why, state),
1113            None => needs("a reason"),
1114        },
1115        Runnable::AllowAlways { task, call } => {
1116            crate::approvals::allow_always(root, feature, task, call, state, via)
1117        }
1118        Runnable::WidenPolicy {
1119            key,
1120            prefix,
1121            digest,
1122        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1123        Runnable::DecideBatch {
1124            run,
1125            verdict,
1126            calls,
1127            digest,
1128            resume_asking,
1129        } => {
1130            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1131            if !approve && text.is_none() {
1132                return needs("a reason");
1133            }
1134            crate::approvals::decide_batch(
1135                root,
1136                feature,
1137                crate::approvals::BatchChoice {
1138                    run,
1139                    approve,
1140                    calls,
1141                    digest,
1142                    resume_asking: *resume_asking,
1143                    reason: text,
1144                },
1145                state,
1146                via,
1147            )
1148        }
1149        Runnable::CheckStory { story } => {
1150            crate::pipeline::story::check_story(root, feature, story, options)
1151        }
1152        Runnable::OracleTests {
1153            req,
1154            op,
1155            path,
1156            scenario,
1157            reading,
1158        } => crate::oracle::tests(
1159            root,
1160            feature,
1161            &crate::oracle::Request {
1162                req,
1163                op: *op,
1164                path: path.as_deref(),
1165                scenario: scenario.as_deref(),
1166                reading: *reading,
1167                text: d.input.filter(|t| !t.trim().is_empty()),
1168            },
1169            via,
1170            state,
1171            options,
1172        ),
1173        Runnable::ApproveAndRun {
1174            mode,
1175            grants,
1176            sheet,
1177        } => crate::review::approve_and_run(
1178            root,
1179            feature,
1180            *mode,
1181            grants,
1182            sheet.as_deref(),
1183            via,
1184            state,
1185            None,
1186            options,
1187        ),
1188        Runnable::Comment { op, anchor, id } => crate::comments::comment(
1189            root,
1190            feature,
1191            *op,
1192            anchor.as_ref(),
1193            id.as_deref(),
1194            text,
1195            via,
1196            state,
1197        ),
1198        Runnable::CommentsSend { ids } => {
1199            crate::comments::send(root, feature, ids, via, state, options)
1200        }
1201        Runnable::Explore {
1202            idea,
1203            skill,
1204            resume,
1205        } => crate::explore::explore(
1206            root,
1207            text.unwrap_or(idea),
1208            skill.as_deref(),
1209            resume.as_deref(),
1210            options,
1211        ),
1212        Runnable::ExploreChoose {
1213            session,
1214            approach,
1215            constraints,
1216            questions,
1217            flag,
1218        } => crate::explore::choose(
1219            root,
1220            state,
1221            session,
1222            *approach,
1223            constraints,
1224            questions,
1225            via,
1226            if *flag { "flag" } else { "prompt" },
1227        ),
1228        Runnable::RevisionUndo { turn } => {
1229            crate::comments::undo(root, feature, turn.as_deref(), state)
1230        }
1231        Runnable::CodeNudge { task, comments } => {
1232            crate::comments::code_nudge(root, feature, task, comments, state)
1233        }
1234        // The format's own skills, in its order; the stops are read from
1235        // the unit's files, so nothing of the chain is kept here.
1236        Runnable::PlanThrough {
1237            from:
1238                speccoder_core::actions::PlanFrom::Create {
1239                    description,
1240                    source,
1241                },
1242            through,
1243        } => {
1244            let description = if description.trim().is_empty() {
1245                text.unwrap_or_default()
1246            } else {
1247                description.as_str()
1248            };
1249            match source {
1250                // The conversations carried come from their own record;
1251                // the new unit's origin is written once it exists.
1252                speccoder_core::actions::FeatureSource::Promotion {
1253                    task,
1254                    conversations,
1255                } => {
1256                    let Some(s) = state else { return no_state() };
1257                    let input = crate::ladder::promotion_input(root, s, description, conversations);
1258                    let made = plan_through(root, &input, source, *through, options);
1259                    crate::ladder::promoted(
1260                        root,
1261                        s,
1262                        &made,
1263                        task,
1264                        conversations,
1265                        via,
1266                        options.progress.as_ref(),
1267                    );
1268                    made
1269                }
1270                // An Explore brief: verbatim and unchanged since it was
1271                // chosen, or nothing runs.
1272                speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1273                    let Some(s) = state else { return no_state() };
1274                    if let Err(refused) =
1275                        crate::explore::verify_start(root, s, session, digest, description)
1276                    {
1277                        return *refused;
1278                    }
1279                    let made = plan_through(root, description, source, *through, options);
1280                    crate::explore::explored(root, s, &made, session, digest, via);
1281                    made
1282                }
1283                _ => plan_through(root, description, source, *through, options),
1284            }
1285        }
1286        Runnable::PlanThrough {
1287            from: speccoder_core::actions::PlanFrom::Continue,
1288            through,
1289        } => crate::planning_chain::resume(root, feature, *through, options),
1290        // The plan the person confirmed, recomputed and compared first.
1291        Runnable::NewProject {
1292            target,
1293            choices,
1294            plan_digest,
1295        } => match state {
1296            Some(s) => crate::new_project::perform(
1297                Path::new(target),
1298                choices,
1299                plan_digest,
1300                via,
1301                s,
1302                options,
1303            ),
1304            None => no_state(),
1305        },
1306        Runnable::UndoNewProject { commit } => match state {
1307            Some(s) => crate::new_project::undo(root, commit, via, s, options),
1308            None => no_state(),
1309        },
1310        // The interface has confirmed it names github.com.
1311        Runnable::CloneRepository { repo, target } => {
1312            crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1313        }
1314        Runnable::OpenSample { fresh } => match state {
1315            Some(s) => crate::sample::open(s, *fresh),
1316            None => no_state(),
1317        },
1318        Runnable::SampleChecks => match state {
1319            Some(s) => crate::sample::run_checks(s, root),
1320            None => no_state(),
1321        },
1322        Runnable::DeclinePromotion { task } => match state {
1323            Some(s) => crate::ladder::decline_task(root, s, task),
1324            None => no_state(),
1325        },
1326        // The interface has confirmed it (the action's `confirm`): push
1327        // and open.
1328        Runnable::PullRequest => crate::handoff::pr(
1329            root,
1330            feature,
1331            &crate::handoff::PrOptions {
1332                remote: "origin",
1333                base: None,
1334                open: true,
1335                force: false,
1336                github: &crate::github::Ctx::from_env(),
1337            },
1338        ),
1339    }
1340}
1341
1342/// The calls a person answered Always Allow inline: each one's exact argv
1343/// joins the allow list through [`perform`] with the interface it was
1344/// answered through. What each did, as notices.
1345#[must_use]
1346pub fn allow_always_inline(
1347    root: &Path,
1348    feature: &str,
1349    always: &[crate::substrate::Always],
1350    state: &Path,
1351) -> Vec<String> {
1352    always
1353        .iter()
1354        .map(|a| {
1355            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1356            let run = Runnable::AllowAlways {
1357                task,
1358                call: a.call.clone(),
1359            };
1360            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1361                .message()
1362                .to_owned()
1363        })
1364        .collect()
1365}
1366
1367/// The person's answer at a stop, given inline at their terminal, taken
1368/// through [`perform`] with the interface it was answered through: I did
1369/// it (`Done`, in the run's in-place session when it has one), the hint
1370/// (`Nudge`: the run then attempts the task again, with its own model) or
1371/// skip (`Skip`).
1372#[must_use]
1373pub fn answered_at_stop(
1374    root: &Path,
1375    feature: Option<&str>,
1376    task: &str,
1377    answer: &crate::approvals::StopAnswer,
1378    (via, run): (Via, &RunOptions),
1379    state: &Path,
1380) -> CommandResult {
1381    use crate::approvals::StopAnswer;
1382    let task = task.to_owned();
1383    let (what, input) = match answer {
1384        StopAnswer::Done => (Runnable::Done { task }, None),
1385        StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1386        StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1387    };
1388    perform_in(
1389        root,
1390        feature,
1391        Dispatch {
1392            input,
1393            options: RunOptions {
1394                in_place: run.in_place,
1395                session: run.session.clone(),
1396                ..RunOptions::default()
1397            },
1398            ..Dispatch::new(&what, via)
1399        },
1400        Some(state),
1401    )
1402}
1403
1404// The arms that run the model, one function each, so a build without the
1405// engine can leave them out without a second dispatcher.
1406
1407/// A planning skill: the person's input, else the arguments it carries.
1408fn skill(
1409    root: &Path,
1410    feature: Option<&str>,
1411    name: &str,
1412    input: Option<&str>,
1413    carried: &str,
1414    options: &RunOptions,
1415) -> CommandResult {
1416    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1417}
1418
1419fn plan_through(
1420    root: &Path,
1421    description: &str,
1422    source: &speccoder_core::actions::FeatureSource,
1423    through: speccoder_core::actions::Through,
1424    options: &RunOptions,
1425) -> CommandResult {
1426    crate::planning_chain::start(root, description, source, through, options)
1427}
1428
1429fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1430    crate::run(root, feature, task, options)
1431}
1432
1433fn run_feature(
1434    root: &Path,
1435    feature: Option<&str>,
1436    options: &RunOptions,
1437    converge: bool,
1438) -> CommandResult {
1439    crate::run_feature(root, feature, options, converge)
1440}
1441
1442fn bug_fix(
1443    root: &Path,
1444    req: &crate::bug::Request,
1445    via: Via,
1446    state: Option<&Path>,
1447    options: &RunOptions,
1448) -> CommandResult {
1449    crate::bug::bug(root, req, via, state, options)
1450}
1451
1452fn bug_confirm(
1453    root: &Path,
1454    task: &str,
1455    digest: &str,
1456    reject: Option<&str>,
1457    via: Via,
1458    state: Option<&Path>,
1459    options: &RunOptions,
1460) -> CommandResult {
1461    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1462}
1463
1464fn bug_run(
1465    root: &Path,
1466    slug: Option<&str>,
1467    task: Option<&str>,
1468    state: Option<&Path>,
1469    options: &RunOptions,
1470) -> CommandResult {
1471    crate::bug::run(root, slug, task, state, options)
1472}
1473
1474fn quick_change(
1475    root: &Path,
1476    request: &str,
1477    checks: &[String],
1478    run: bool,
1479    via: Via,
1480    options: &RunOptions,
1481    asked: Option<(&str, &Path)>,
1482) -> CommandResult {
1483    crate::quick::change(root, request, checks, run, via, options, asked)
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488    use speccoder_core::actions::primary;
1489
1490    use super::*;
1491
1492    #[test]
1493    fn every_interface_decides_every_kind() {
1494        let mut taken = 0;
1495        for i in Interface::ALL {
1496            for k in ActionKind::ALL {
1497                if offered(*i, *k) == Offer::Take {
1498                    taken += 1;
1499                } else {
1500                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1501                }
1502            }
1503        }
1504        // The web host runs every runnable kind; the terminal the steps
1505        // it offers (plan through's continuation among them); the remedies
1506        // only the setup's fix.
1507        let runnable = Runnable::examples().len();
1508        // The dashboard also decides Always Allow and batches; an Ask
1509        // proposal is one of five kinds.
1510        assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1511        assert_eq!(
1512            offered(Interface::CliFollow, ActionKind::PullRequest),
1513            Offer::Refuse
1514        );
1515        assert_eq!(
1516            offered(Interface::AcpRemedy, ActionKind::FixSetup),
1517            Offer::Take
1518        );
1519    }
1520
1521    /// The speccoder commands no interface takes: developer tools, and a
1522    /// pull request to a remote only the person can name.
1523    fn copied(line: &str) -> bool {
1524        let mut w = line.split_whitespace().skip(1);
1525        matches!(w.next(), Some("eval" | "replay" | "bench"))
1526            || line.contains("--remote <")
1527            // Another project's command: run there.
1528            || line.split_whitespace().any(|w| w == "-C")
1529            // A line about a command's own flags (`{command} --help`): said.
1530            || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1531                && line.split_whitespace().count() == 3
1532    }
1533
1534    /// String literals that start `speccoder ` and are a message's prose,
1535    /// not a command (the start of each). A new one is added here only
1536    /// when it is no command.
1537    const PROSE: &[&str] = &[
1538        "speccoder did not ",
1539        "speccoder cannot ",
1540        "speccoder commits ",
1541        "speccoder could ",
1542        "speccoder failed ",
1543        "speccoder has ",
1544        "speccoder is ",
1545        "speccoder needs ",
1546        "speccoder reads ",
1547        "speccoder works ",
1548        "speccoder does not ",
1549        "speccoder implements ",
1550        "speccoder converges ",
1551        "speccoder runs ",
1552        "speccoder Documentation",
1553        "speccoder — ",
1554        "speccoder ✓",
1555        "speccoder ■",
1556        "speccoder ▸",
1557        "speccoder △",
1558        "speccoder ✗",
1559        "speccoder / ",
1560        "speccoder attempt: ",
1561        "speccoder manifest: ",
1562        "speccoder inspect: ",
1563        "speccoder preset ",
1564        "speccoder bundles",
1565        "speccoder {}: ",
1566        "speccoder {feature}: ",
1567        "speccoder 001-x: ",
1568        "speccoder {} · ",
1569        // The command the person typed, echoed.
1570        "speccoder {} --yes",
1571    ];
1572
1573    /// Every `speccoder …` command the catalog and every builder print
1574    /// (each string literal of the core, runtime, web and CLI sources,
1575    /// placeholders filled) is performed, opened as a view or run as one
1576    /// of speccoder's own commands — never only copied, and never words
1577    /// the parser could not read.
1578    #[test]
1579    fn no_speccoder_command_is_copy_only() {
1580        let ctx = Context {
1581            feature: Some("001-x"),
1582            task: Some("T001"),
1583        };
1584        let mut seen = 0;
1585        let mut errors = Vec::new();
1586        for e in speccoder_core::codes::CATALOG {
1587            for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1588                if line.starts_with("speccoder ") && !copied(&line) {
1589                    let how = runnable_of(&line, ctx).how;
1590                    if how.copy_only() {
1591                        errors.push(format!("{}: {line} → {how:?}", e.name));
1592                    }
1593                    seen += 1;
1594                }
1595            }
1596        }
1597        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1598        let mut files = Vec::new();
1599        for dir in [
1600            "src",
1601            "../speccoder-core/src",
1602            "../speccoder-web/src",
1603            "../speccoder/src",
1604        ] {
1605            let mut stack = vec![root.join(dir)];
1606            while let Some(d) = stack.pop() {
1607                for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1608                    let p = entry.path();
1609                    if p.is_dir() {
1610                        stack.push(p);
1611                    } else if p.extension().is_some_and(|x| x == "rs") {
1612                        files.push(p);
1613                    }
1614                }
1615            }
1616        }
1617        assert!(files.len() > 100, "{} files", files.len());
1618        for f in files {
1619            let src = std::fs::read_to_string(&f).unwrap_or_default();
1620            // A test's lines are its cases, not what speccoder prints.
1621            let src = src
1622                .split("#[cfg(test)]\nmod tests")
1623                .next()
1624                .unwrap_or_default();
1625            for (at, _) in src.match_indices("\"speccoder ") {
1626                // A quote within another literal is no literal.
1627                if src[..at].ends_with('\\') {
1628                    continue;
1629                }
1630                let mut line = String::new();
1631                let mut chars = src[at + 1..].chars();
1632                while let Some(c) = chars.next() {
1633                    match c {
1634                        '\\' => line.extend(chars.next()),
1635                        '"' => break,
1636                        c => line.push(c),
1637                    }
1638                }
1639                // A format string's arguments, filled.
1640                let mut filled = String::new();
1641                let mut rest = line.as_str();
1642                while let Some(open) = rest.find('{') {
1643                    filled.push_str(&rest[..open]);
1644                    let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1645                    // A suffix (`doctor{f}`: ` --feature F` or nothing).
1646                    let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1647                    filled.push_str(match &rest[open..close] {
1648                        _ if suffix => "",
1649                        "{task}" | "{t}" => "T001",
1650                        "{feature}" | "{f}" => "001-x",
1651                        "{command}" | "{verb}" => "run",
1652                        _ if filled.ends_with("--through ") => "tasks",
1653                        _ => "x",
1654                    });
1655                    rest = &rest[close.min(rest.len())..];
1656                }
1657                filled.push_str(rest);
1658                // A prefix compared against, no line.
1659                if line == "speccoder "
1660                    || copied(&filled)
1661                    || PROSE.iter().any(|p| line.starts_with(p))
1662                {
1663                    continue;
1664                }
1665                let how = runnable_of(&filled, ctx).how;
1666                if how.copy_only() {
1667                    errors.push(format!("{}: {filled} → {how:?}", f.display()));
1668                }
1669                seen += 1;
1670            }
1671        }
1672        assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1673        assert!(seen > 100, "{seen} lines seen");
1674    }
1675
1676    #[test]
1677    fn blank_input_is_no_input() {
1678        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1679        let run = Runnable::Reject {
1680            task: "T001".to_owned(),
1681            call: "attempt:T001".to_owned(),
1682            digest: String::new(),
1683        };
1684        let r = perform_in(
1685            dir.path(),
1686            None,
1687            Dispatch {
1688                run: &run,
1689                input: Some("  "),
1690                via: Via::Web,
1691                options: RunOptions::default(),
1692            },
1693            None,
1694        );
1695        assert_eq!(
1696            (r.command(), r.status(), r.message()),
1697            ("job", CommandStatus::UsageError, "a reason is needed")
1698        );
1699    }
1700
1701    #[test]
1702    fn a_project_outside_git_still_gets_a_list() {
1703        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1704        let list = available(dir.path(), None, None);
1705        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1706        assert!(primary(&list).is_none());
1707    }
1708}