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::RuleScenario
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::RuleScenario
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::RuleScenario
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::RuleScenario
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::RuleScenario
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::RuleScenario { task, scenario } => match (text, state) {
913            (Some(why), Some(s)) => crate::acceptance_at::rule_tests_wrong(
914                root,
915                feature,
916                task,
917                scenario.as_deref(),
918                why,
919                via,
920                s,
921            ),
922            (None, _) => needs("a reason"),
923            (_, None) => no_state(),
924        },
925        // The interface has confirmed: Spec Kit's own command runs.
926        Runnable::PresetAdd { update } => match state {
927            Some(s) => crate::preset::add(root, *update, true, s),
928            None => no_state(),
929        },
930        Runnable::Skip { task } => match text {
931            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
932            None => needs("a reason"),
933        },
934        Runnable::Nudge { task } => match (text, state) {
935            (Some(note), Some(s)) => {
936                crate::controls::nudge_with(root, feature, task, Some(note), s)
937            }
938            (None, _) => needs("a note"),
939            (_, None) => no_state(),
940        },
941        // I did it: the person's word, in the words they gave or these.
942        // In place when the dispatch is (an answer at an in-place run's stop).
943        Runnable::Done { task } => match state {
944            Some(s) => crate::controls::accept_in_session(
945                root,
946                feature,
947                task,
948                text.unwrap_or(DONE_REASON),
949                s,
950                options,
951            ),
952            None => no_state(),
953        },
954        // Hint & retry: the note first; a new attempt only once it is kept.
955        Runnable::Hint { task } => match (text, state) {
956            (Some(note), Some(s)) => {
957                let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
958                if noted.status() == CommandStatus::Ok {
959                    run_task(root, feature, task, options)
960                } else {
961                    noted
962                }
963            }
964            (None, _) => needs("a hint"),
965            (_, None) => no_state(),
966        },
967        Runnable::Rule { task } => match (text, state) {
968            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
969            (None, _) => needs("a reason"),
970            (_, None) => no_state(),
971        },
972        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
973            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
974            ("edit" | "edit-approve", Some(id), Some(t)) => {
975                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
976            }
977            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
978            _ => crate::review::decide(root, feature, ids, verdict, text, via),
979        },
980        Runnable::IssueComment { issue, assign } => crate::github::comment(
981            &crate::github::Ctx::from_env(),
982            root,
983            feature,
984            &issue.to_string(),
985            *assign,
986        ),
987        Runnable::QuickChange {
988            request,
989            checks,
990            run,
991            asked,
992        } => quick_change(
993            root,
994            request,
995            checks,
996            *run,
997            via,
998            options,
999            asked.as_deref().zip(state),
1000        ),
1001        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
1002        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
1003        Runnable::FixSetup => crate::code::fix_setup(root, feature),
1004        Runnable::Feature => match text {
1005            Some(name) => crate::current::switch(root, name),
1006            None => needs("a feature"),
1007        },
1008        Runnable::BugFix {
1009            symptom,
1010            expected,
1011            unchanged,
1012            issue,
1013            tests,
1014            candidate,
1015            slug,
1016            run,
1017            asked,
1018        } => bug_fix(
1019            root,
1020            &crate::bug::Request {
1021                asked: asked.clone(),
1022                ..crate::bug::Request::of(
1023                    symptom,
1024                    expected,
1025                    unchanged,
1026                    *issue,
1027                    tests,
1028                    candidate.as_deref(),
1029                    slug.as_deref(),
1030                    *run,
1031                )
1032            },
1033            via,
1034            state,
1035            options,
1036        ),
1037        Runnable::BugConfirm {
1038            task,
1039            digest,
1040            reject,
1041        } => match (*reject, text) {
1042            (true, None) => needs("a reason"),
1043            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1044            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1045        },
1046        Runnable::BugRun { slug, task } => {
1047            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1048        }
1049        Runnable::BugKeep { task } => match text {
1050            Some(why) => crate::bug::keep(root, task, why, state),
1051            None => needs("a reason"),
1052        },
1053        Runnable::AllowAlways { task, call } => {
1054            crate::approvals::allow_always(root, feature, task, call, state, via)
1055        }
1056        Runnable::WidenPolicy {
1057            key,
1058            prefix,
1059            digest,
1060        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1061        Runnable::DecideBatch {
1062            run,
1063            verdict,
1064            calls,
1065            digest,
1066            resume_asking,
1067        } => {
1068            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1069            if !approve && text.is_none() {
1070                return needs("a reason");
1071            }
1072            crate::approvals::decide_batch(
1073                root,
1074                feature,
1075                crate::approvals::BatchChoice {
1076                    run,
1077                    approve,
1078                    calls,
1079                    digest,
1080                    resume_asking: *resume_asking,
1081                    reason: text,
1082                },
1083                state,
1084                via,
1085            )
1086        }
1087        Runnable::OracleTests {
1088            req,
1089            op,
1090            path,
1091            scenario,
1092            reading,
1093        } => crate::oracle::tests(
1094            root,
1095            feature,
1096            &crate::oracle::Request {
1097                req,
1098                op: *op,
1099                path: path.as_deref(),
1100                scenario: scenario.as_deref(),
1101                reading: *reading,
1102                text: d.input.filter(|t| !t.trim().is_empty()),
1103            },
1104            via,
1105            state,
1106            options,
1107        ),
1108        Runnable::ApproveAndRun {
1109            mode,
1110            grants,
1111            sheet,
1112        } => crate::review::approve_and_run(
1113            root,
1114            feature,
1115            *mode,
1116            grants,
1117            sheet.as_deref(),
1118            via,
1119            state,
1120            None,
1121            options,
1122        ),
1123        Runnable::Comment { op, anchor, id } => crate::comments::comment(
1124            root,
1125            feature,
1126            *op,
1127            anchor.as_ref(),
1128            id.as_deref(),
1129            text,
1130            via,
1131            state,
1132        ),
1133        Runnable::CommentsSend { ids } => {
1134            crate::comments::send(root, feature, ids, via, state, options)
1135        }
1136        Runnable::Explore {
1137            idea,
1138            skill,
1139            resume,
1140        } => crate::explore::explore(
1141            root,
1142            text.unwrap_or(idea),
1143            skill.as_deref(),
1144            resume.as_deref(),
1145            options,
1146        ),
1147        Runnable::ExploreChoose {
1148            session,
1149            approach,
1150            constraints,
1151            questions,
1152            flag,
1153        } => crate::explore::choose(
1154            root,
1155            state,
1156            session,
1157            *approach,
1158            constraints,
1159            questions,
1160            via,
1161            if *flag { "flag" } else { "prompt" },
1162        ),
1163        Runnable::RevisionUndo { turn } => {
1164            crate::comments::undo(root, feature, turn.as_deref(), state)
1165        }
1166        Runnable::CodeNudge { task, comments } => {
1167            crate::comments::code_nudge(root, feature, task, comments, state)
1168        }
1169        // The format's own skills, in its order; the stops are read from
1170        // the unit's files, so nothing of the chain is kept here.
1171        Runnable::PlanThrough {
1172            from:
1173                speccoder_core::actions::PlanFrom::Create {
1174                    description,
1175                    source,
1176                },
1177            through,
1178        } => {
1179            let description = if description.trim().is_empty() {
1180                text.unwrap_or_default()
1181            } else {
1182                description.as_str()
1183            };
1184            match source {
1185                // The conversations carried come from their own record;
1186                // the new unit's origin is written once it exists.
1187                speccoder_core::actions::FeatureSource::Promotion {
1188                    task,
1189                    conversations,
1190                } => {
1191                    let Some(s) = state else { return no_state() };
1192                    let input = crate::ladder::promotion_input(root, s, description, conversations);
1193                    let made = plan_through(root, &input, source, *through, options);
1194                    crate::ladder::promoted(
1195                        root,
1196                        s,
1197                        &made,
1198                        task,
1199                        conversations,
1200                        via,
1201                        options.progress.as_ref(),
1202                    );
1203                    made
1204                }
1205                // An Explore brief: verbatim and unchanged since it was
1206                // chosen, or nothing runs.
1207                speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1208                    let Some(s) = state else { return no_state() };
1209                    if let Err(refused) =
1210                        crate::explore::verify_start(root, s, session, digest, description)
1211                    {
1212                        return *refused;
1213                    }
1214                    let made = plan_through(root, description, source, *through, options);
1215                    crate::explore::explored(root, s, &made, session, digest, via);
1216                    made
1217                }
1218                _ => plan_through(root, description, source, *through, options),
1219            }
1220        }
1221        Runnable::PlanThrough {
1222            from: speccoder_core::actions::PlanFrom::Continue,
1223            through,
1224        } => crate::planning_chain::resume(root, feature, *through, options),
1225        // The plan the person confirmed, recomputed and compared first.
1226        Runnable::NewProject {
1227            target,
1228            choices,
1229            plan_digest,
1230        } => match state {
1231            Some(s) => crate::new_project::perform(
1232                Path::new(target),
1233                choices,
1234                plan_digest,
1235                via,
1236                s,
1237                options,
1238            ),
1239            None => no_state(),
1240        },
1241        Runnable::UndoNewProject { commit } => match state {
1242            Some(s) => crate::new_project::undo(root, commit, via, s, options),
1243            None => no_state(),
1244        },
1245        // The interface has confirmed it names github.com.
1246        Runnable::CloneRepository { repo, target } => {
1247            crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1248        }
1249        Runnable::OpenSample { fresh } => match state {
1250            Some(s) => crate::sample::open(s, *fresh),
1251            None => no_state(),
1252        },
1253        Runnable::SampleChecks => match state {
1254            Some(s) => crate::sample::run_checks(s, root),
1255            None => no_state(),
1256        },
1257        Runnable::DeclinePromotion { task } => match state {
1258            Some(s) => crate::ladder::decline_task(root, s, task),
1259            None => no_state(),
1260        },
1261        // The interface has confirmed it (the action's `confirm`): push
1262        // and open.
1263        Runnable::PullRequest => crate::handoff::pr(
1264            root,
1265            feature,
1266            &crate::handoff::PrOptions {
1267                remote: "origin",
1268                base: None,
1269                open: true,
1270                force: false,
1271                github: &crate::github::Ctx::from_env(),
1272            },
1273        ),
1274    }
1275}
1276
1277/// The calls a person answered Always Allow inline: each one's exact argv
1278/// joins the allow list through [`perform`] with the interface it was
1279/// answered through. What each did, as notices.
1280#[must_use]
1281pub fn allow_always_inline(
1282    root: &Path,
1283    feature: &str,
1284    always: &[crate::substrate::Always],
1285    state: &Path,
1286) -> Vec<String> {
1287    always
1288        .iter()
1289        .map(|a| {
1290            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1291            let run = Runnable::AllowAlways {
1292                task,
1293                call: a.call.clone(),
1294            };
1295            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1296                .message()
1297                .to_owned()
1298        })
1299        .collect()
1300}
1301
1302/// The person's answer at a stop, given inline at their terminal, taken
1303/// through [`perform`] with the interface it was answered through: I did
1304/// it (`Done`, in the run's in-place session when it has one), the hint
1305/// (`Nudge`: the run then attempts the task again, with its own model) or
1306/// skip (`Skip`).
1307#[must_use]
1308pub fn answered_at_stop(
1309    root: &Path,
1310    feature: Option<&str>,
1311    task: &str,
1312    answer: &crate::approvals::StopAnswer,
1313    (via, run): (Via, &RunOptions),
1314    state: &Path,
1315) -> CommandResult {
1316    use crate::approvals::StopAnswer;
1317    let task = task.to_owned();
1318    let (what, input) = match answer {
1319        StopAnswer::Done => (Runnable::Done { task }, None),
1320        StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1321        StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1322    };
1323    perform_in(
1324        root,
1325        feature,
1326        Dispatch {
1327            input,
1328            options: RunOptions {
1329                in_place: run.in_place,
1330                session: run.session.clone(),
1331                ..RunOptions::default()
1332            },
1333            ..Dispatch::new(&what, via)
1334        },
1335        Some(state),
1336    )
1337}
1338
1339// The arms that run the model, one function each, so a build without the
1340// engine can leave them out without a second dispatcher.
1341
1342/// A planning skill: the person's input, else the arguments it carries.
1343fn skill(
1344    root: &Path,
1345    feature: Option<&str>,
1346    name: &str,
1347    input: Option<&str>,
1348    carried: &str,
1349    options: &RunOptions,
1350) -> CommandResult {
1351    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1352}
1353
1354fn plan_through(
1355    root: &Path,
1356    description: &str,
1357    source: &speccoder_core::actions::FeatureSource,
1358    through: speccoder_core::actions::Through,
1359    options: &RunOptions,
1360) -> CommandResult {
1361    crate::planning_chain::start(root, description, source, through, options)
1362}
1363
1364fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1365    crate::run(root, feature, task, options)
1366}
1367
1368fn run_feature(
1369    root: &Path,
1370    feature: Option<&str>,
1371    options: &RunOptions,
1372    converge: bool,
1373) -> CommandResult {
1374    crate::run_feature(root, feature, options, converge)
1375}
1376
1377fn bug_fix(
1378    root: &Path,
1379    req: &crate::bug::Request,
1380    via: Via,
1381    state: Option<&Path>,
1382    options: &RunOptions,
1383) -> CommandResult {
1384    crate::bug::bug(root, req, via, state, options)
1385}
1386
1387fn bug_confirm(
1388    root: &Path,
1389    task: &str,
1390    digest: &str,
1391    reject: Option<&str>,
1392    via: Via,
1393    state: Option<&Path>,
1394    options: &RunOptions,
1395) -> CommandResult {
1396    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1397}
1398
1399fn bug_run(
1400    root: &Path,
1401    slug: Option<&str>,
1402    task: Option<&str>,
1403    state: Option<&Path>,
1404    options: &RunOptions,
1405) -> CommandResult {
1406    crate::bug::run(root, slug, task, state, options)
1407}
1408
1409fn quick_change(
1410    root: &Path,
1411    request: &str,
1412    checks: &[String],
1413    run: bool,
1414    via: Via,
1415    options: &RunOptions,
1416    asked: Option<(&str, &Path)>,
1417) -> CommandResult {
1418    crate::quick::change(root, request, checks, run, via, options, asked)
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423    use speccoder_core::actions::primary;
1424
1425    use super::*;
1426
1427    #[test]
1428    fn every_interface_decides_every_kind() {
1429        let mut taken = 0;
1430        for i in Interface::ALL {
1431            for k in ActionKind::ALL {
1432                if offered(*i, *k) == Offer::Take {
1433                    taken += 1;
1434                } else {
1435                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1436                }
1437            }
1438        }
1439        // The web host runs every runnable kind; the terminal the steps
1440        // it offers (plan through's continuation among them); the remedies
1441        // only the setup's fix.
1442        let runnable = Runnable::examples().len();
1443        // The dashboard also decides Always Allow and batches; an Ask
1444        // proposal is one of five kinds.
1445        assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1446        assert_eq!(
1447            offered(Interface::CliFollow, ActionKind::PullRequest),
1448            Offer::Refuse
1449        );
1450        assert_eq!(
1451            offered(Interface::AcpRemedy, ActionKind::FixSetup),
1452            Offer::Take
1453        );
1454    }
1455
1456    /// The speccoder commands no interface takes: developer tools, and a
1457    /// pull request to a remote only the person can name.
1458    fn copied(line: &str) -> bool {
1459        let mut w = line.split_whitespace().skip(1);
1460        matches!(w.next(), Some("eval" | "replay" | "bench"))
1461            || line.contains("--remote <")
1462            // Another project's command: run there.
1463            || line.split_whitespace().any(|w| w == "-C")
1464            // A line about a command's own flags (`{command} --help`): said.
1465            || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1466                && line.split_whitespace().count() == 3
1467    }
1468
1469    /// String literals that start `speccoder ` and are a message's prose,
1470    /// not a command (the start of each). A new one is added here only
1471    /// when it is no command.
1472    const PROSE: &[&str] = &[
1473        "speccoder did not ",
1474        "speccoder cannot ",
1475        "speccoder commits ",
1476        "speccoder could ",
1477        "speccoder failed ",
1478        "speccoder has ",
1479        "speccoder is ",
1480        "speccoder needs ",
1481        "speccoder reads ",
1482        "speccoder works ",
1483        "speccoder does not ",
1484        "speccoder implements ",
1485        "speccoder converges ",
1486        "speccoder runs ",
1487        "speccoder Documentation",
1488        "speccoder — ",
1489        "speccoder ✓",
1490        "speccoder ■",
1491        "speccoder ▸",
1492        "speccoder △",
1493        "speccoder ✗",
1494        "speccoder / ",
1495        "speccoder attempt: ",
1496        "speccoder manifest: ",
1497        "speccoder inspect: ",
1498        "speccoder preset ",
1499        "speccoder bundles",
1500        "speccoder {}: ",
1501        "speccoder {feature}: ",
1502        "speccoder 001-x: ",
1503        "speccoder {} · ",
1504        // The command the person typed, echoed.
1505        "speccoder {} --yes",
1506    ];
1507
1508    /// Every `speccoder …` command the catalog and every builder print
1509    /// (each string literal of the core, runtime, web and CLI sources,
1510    /// placeholders filled) is performed, opened as a view or run as one
1511    /// of speccoder's own commands — never only copied, and never words
1512    /// the parser could not read.
1513    #[test]
1514    fn no_speccoder_command_is_copy_only() {
1515        let ctx = Context {
1516            feature: Some("001-x"),
1517            task: Some("T001"),
1518        };
1519        let mut seen = 0;
1520        let mut errors = Vec::new();
1521        for e in speccoder_core::codes::CATALOG {
1522            for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1523                if line.starts_with("speccoder ") && !copied(&line) {
1524                    let how = runnable_of(&line, ctx).how;
1525                    if how.copy_only() {
1526                        errors.push(format!("{}: {line} → {how:?}", e.name));
1527                    }
1528                    seen += 1;
1529                }
1530            }
1531        }
1532        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1533        let mut files = Vec::new();
1534        for dir in [
1535            "src",
1536            "../speccoder-core/src",
1537            "../speccoder-web/src",
1538            "../speccoder/src",
1539        ] {
1540            let mut stack = vec![root.join(dir)];
1541            while let Some(d) = stack.pop() {
1542                for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1543                    let p = entry.path();
1544                    if p.is_dir() {
1545                        stack.push(p);
1546                    } else if p.extension().is_some_and(|x| x == "rs") {
1547                        files.push(p);
1548                    }
1549                }
1550            }
1551        }
1552        assert!(files.len() > 100, "{} files", files.len());
1553        for f in files {
1554            let src = std::fs::read_to_string(&f).unwrap_or_default();
1555            // A test's lines are its cases, not what speccoder prints.
1556            let src = src
1557                .split("#[cfg(test)]\nmod tests")
1558                .next()
1559                .unwrap_or_default();
1560            for (at, _) in src.match_indices("\"speccoder ") {
1561                // A quote within another literal is no literal.
1562                if src[..at].ends_with('\\') {
1563                    continue;
1564                }
1565                let mut line = String::new();
1566                let mut chars = src[at + 1..].chars();
1567                while let Some(c) = chars.next() {
1568                    match c {
1569                        '\\' => line.extend(chars.next()),
1570                        '"' => break,
1571                        c => line.push(c),
1572                    }
1573                }
1574                // A format string's arguments, filled.
1575                let mut filled = String::new();
1576                let mut rest = line.as_str();
1577                while let Some(open) = rest.find('{') {
1578                    filled.push_str(&rest[..open]);
1579                    let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1580                    // A suffix (`doctor{f}`: ` --feature F` or nothing).
1581                    let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1582                    filled.push_str(match &rest[open..close] {
1583                        _ if suffix => "",
1584                        "{task}" | "{t}" => "T001",
1585                        "{feature}" | "{f}" => "001-x",
1586                        "{command}" | "{verb}" => "run",
1587                        _ if filled.ends_with("--through ") => "tasks",
1588                        _ => "x",
1589                    });
1590                    rest = &rest[close.min(rest.len())..];
1591                }
1592                filled.push_str(rest);
1593                // A prefix compared against, no line.
1594                if line == "speccoder "
1595                    || copied(&filled)
1596                    || PROSE.iter().any(|p| line.starts_with(p))
1597                {
1598                    continue;
1599                }
1600                let how = runnable_of(&filled, ctx).how;
1601                if how.copy_only() {
1602                    errors.push(format!("{}: {filled} → {how:?}", f.display()));
1603                }
1604                seen += 1;
1605            }
1606        }
1607        assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1608        assert!(seen > 100, "{seen} lines seen");
1609    }
1610
1611    #[test]
1612    fn blank_input_is_no_input() {
1613        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1614        let run = Runnable::Reject {
1615            task: "T001".to_owned(),
1616            call: "attempt:T001".to_owned(),
1617            digest: String::new(),
1618        };
1619        let r = perform_in(
1620            dir.path(),
1621            None,
1622            Dispatch {
1623                run: &run,
1624                input: Some("  "),
1625                via: Via::Web,
1626                options: RunOptions::default(),
1627            },
1628            None,
1629        );
1630        assert_eq!(
1631            (r.command(), r.status(), r.message()),
1632            ("job", CommandStatus::UsageError, "a reason is needed")
1633        );
1634    }
1635
1636    #[test]
1637    fn a_project_outside_git_still_gets_a_list() {
1638        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1639        let list = available(dir.path(), None, None);
1640        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1641        assert!(primary(&list).is_none());
1642    }
1643}