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