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