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