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