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::AcceptUntestable
593 | K::PresetAdd
594 | K::PlanThrough
595 | K::NewProject
596 | K::UndoNewProject
597 | K::CloneRepository
598 | K::OpenSample
599 | K::SampleChecks
600 | K::DeclinePromotion
601 | K::Explore
602 | K::ExploreChoose => Offer::Take,
603 K::Command => Offer::Refuse,
604 },
605 Interface::CliFollow => match kind {
608 K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
610 Offer::Take
611 }
612 K::Approve
613 | K::Reject
614 | K::Accept
615 | K::Skip
616 | K::Nudge
617 | K::Done
618 | K::Hint
619 | K::Rule
620 | K::Review
621 | K::IssueComment
622 | K::QuickChange
623 | K::KeepSetAside
624 | K::DropSetAside
625 | K::FixSetup
626 | K::Feature
627 | K::PullRequest
628 | K::BugFix
629 | K::BugConfirm
630 | K::BugRun
631 | K::BugKeep
632 | K::AllowAlways
633 | K::WidenPolicy
634 | K::DecideBatch
635 | K::OracleTests
636 | K::CheckStory
637 | K::ApproveAndRun
638 | K::Comment
639 | K::CommentsSend
640 | K::RevisionUndo
641 | K::CodeNudge
642 | K::AcceptanceAt
643 | K::RuleScenario
644 | K::AcceptUntestable
645 | K::PresetAdd
646 | K::NewProject
647 | K::UndoNewProject
648 | K::CloneRepository
649 | K::OpenSample
650 | K::SampleChecks
651 | K::DeclinePromotion
652 | K::Explore
653 | K::ExploreChoose
654 | K::Command => Offer::Refuse,
655 },
656 Interface::AskProposal => match kind {
659 K::QuickChange | K::BugFix | K::Nudge | K::Review | K::PlanThrough => Offer::Take,
660 K::Skill
661 | K::RunTask
662 | K::RunFeature
663 | K::Converge
664 | K::Commit
665 | K::Approve
666 | K::Reject
667 | K::Accept
668 | K::Skip
669 | K::Done
670 | K::Hint
671 | K::Rule
672 | K::IssueComment
673 | K::KeepSetAside
674 | K::DropSetAside
675 | K::FixSetup
676 | K::Feature
677 | K::PullRequest
678 | K::BugConfirm
679 | K::BugRun
680 | K::BugKeep
681 | K::AllowAlways
682 | K::WidenPolicy
683 | K::DecideBatch
684 | K::OracleTests
685 | K::CheckStory
686 | K::ApproveAndRun
687 | K::Comment
688 | K::CommentsSend
689 | K::RevisionUndo
690 | K::CodeNudge
691 | K::AcceptanceAt
692 | K::RuleScenario
693 | K::AcceptUntestable
694 | K::PresetAdd
695 | K::NewProject
696 | K::UndoNewProject
697 | K::CloneRepository
698 | K::OpenSample
699 | K::SampleChecks
700 | K::DeclinePromotion
701 | K::Explore
702 | K::ExploreChoose
703 | K::Command => Offer::Refuse,
704 },
705 Interface::Dashboard => match kind {
708 K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
709 K::Skill
710 | K::RunTask
711 | K::RunFeature
712 | K::Converge
713 | K::Commit
714 | K::Accept
715 | K::Skip
716 | K::Nudge
717 | K::Done
718 | K::Hint
719 | K::Rule
720 | K::Review
721 | K::IssueComment
722 | K::QuickChange
723 | K::KeepSetAside
724 | K::DropSetAside
725 | K::FixSetup
726 | K::PullRequest
727 | K::BugFix
728 | K::BugConfirm
729 | K::BugRun
730 | K::BugKeep
731 | K::WidenPolicy
732 | K::OracleTests
733 | K::CheckStory
734 | K::ApproveAndRun
735 | K::Comment
736 | K::CommentsSend
737 | K::RevisionUndo
738 | K::CodeNudge
739 | K::AcceptanceAt
740 | K::RuleScenario
741 | K::AcceptUntestable
742 | K::PresetAdd
743 | K::PlanThrough
744 | K::NewProject
745 | K::UndoNewProject
746 | K::CloneRepository
747 | K::OpenSample
748 | K::SampleChecks
749 | K::DeclinePromotion
750 | K::Explore
751 | K::ExploreChoose
752 | K::Command => Offer::Refuse,
753 },
754 Interface::CliRemedy | Interface::AcpRemedy => match kind {
755 K::FixSetup => Offer::Take,
756 K::Skill
757 | K::RunTask
758 | K::RunFeature
759 | K::Converge
760 | K::Commit
761 | K::Approve
762 | K::Reject
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::Feature
775 | K::PullRequest
776 | K::BugFix
777 | K::BugConfirm
778 | K::BugRun
779 | K::BugKeep
780 | K::AllowAlways
781 | K::WidenPolicy
782 | K::DecideBatch
783 | K::OracleTests
784 | K::CheckStory
785 | K::ApproveAndRun
786 | K::Comment
787 | K::CommentsSend
788 | K::RevisionUndo
789 | K::CodeNudge
790 | K::AcceptanceAt
791 | K::RuleScenario
792 | K::AcceptUntestable
793 | K::PresetAdd
794 | K::PlanThrough
795 | K::NewProject
796 | K::UndoNewProject
797 | K::CloneRepository
798 | K::OpenSample
799 | K::SampleChecks
800 | K::DeclinePromotion
801 | K::Explore
802 | K::ExploreChoose
803 | K::Command => Offer::Refuse,
804 },
805 }
806}
807
808#[derive(Debug)]
812pub struct Dispatch<'a> {
813 pub run: &'a Runnable,
815 pub input: Option<&'a str>,
818 pub via: Via,
820 pub options: RunOptions,
822}
823
824impl<'a> Dispatch<'a> {
825 #[must_use]
827 pub fn new(run: &'a Runnable, via: Via) -> Self {
828 Self {
829 run,
830 input: None,
831 via,
832 options: RunOptions::default(),
833 }
834 }
835}
836
837#[must_use]
840pub fn perform_for(
841 interface: Interface,
842 root: &Path,
843 feature: Option<&str>,
844 d: Dispatch<'_>,
845) -> Option<CommandResult> {
846 (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
847}
848
849#[must_use]
854pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
855 perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
856}
857
858fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
860 RunOptions {
861 agent: agent.cloned().or_else(|| options.agent.clone()),
862 ..options.clone()
863 }
864}
865
866#[must_use]
868pub fn perform_in(
869 root: &Path,
870 feature: Option<&str>,
871 d: Dispatch<'_>,
872 state: Option<&Path>,
873) -> CommandResult {
874 use crate::approvals::{Choice, Verb};
875 let needs = |what: &str| {
876 CommandResult::new(
877 "job",
878 CommandStatus::UsageError,
879 &format!("{what} is needed"),
880 )
881 };
882 let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
883 let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
884 let options = &d.options;
885 let via = d.via;
886 let text = d.input.map(str::trim).filter(|t| !t.is_empty());
887 match d.run {
888 Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
889 Runnable::Run {
890 task: Some(t),
891 agent,
892 } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
893 Runnable::Run { task: None, agent } => {
894 run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
895 }
896 Runnable::Converge => run_feature(root, feature, options, true),
897 Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
898 Runnable::Approve { task, call, digest } => decide(Verb::Decide(
899 Choice::approve(task, text).shown(call, digest),
900 )),
901 Runnable::Reject { task, call, digest } => match text {
902 Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
903 None => needs("a reason"),
904 },
905 Runnable::Accept { task } => match (text, state) {
906 (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
907 (None, _) => needs("a reason"),
908 (_, None) => no_state(),
909 },
910 Runnable::AcceptanceAt {
912 feature: unit,
913 story,
914 task,
915 revoke,
916 } => match state {
917 Some(s) => {
918 crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
919 }
920 None => no_state(),
921 },
922 Runnable::RuleScenario { task, scenario } => match (text, state) {
923 (Some(why), Some(s)) => crate::acceptance_at::rule_tests_wrong(
924 root,
925 feature,
926 task,
927 scenario.as_deref(),
928 why,
929 via,
930 s,
931 ),
932 (None, _) => needs("a reason"),
933 (_, None) => no_state(),
934 },
935 Runnable::AcceptUntestable { scenario } => match (text, state) {
936 (Some(why), Some(s)) => {
937 crate::acceptance_at::accept_untestable(root, feature, scenario, why, via, s)
938 }
939 (None, _) => needs("a reason"),
940 (_, None) => no_state(),
941 },
942 Runnable::PresetAdd { update } => match state {
944 Some(s) => crate::preset::add(root, *update, true, s),
945 None => no_state(),
946 },
947 Runnable::Skip { task } => match text {
948 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
949 None => needs("a reason"),
950 },
951 Runnable::Nudge { task } => match (text, state) {
952 (Some(note), Some(s)) => {
953 crate::controls::nudge_with(root, feature, task, Some(note), s)
954 }
955 (None, _) => needs("a note"),
956 (_, None) => no_state(),
957 },
958 Runnable::Done { task } => match state {
961 Some(s) => crate::controls::accept_in_session(
962 root,
963 feature,
964 task,
965 text.unwrap_or(DONE_REASON),
966 s,
967 options,
968 ),
969 None => no_state(),
970 },
971 Runnable::Hint { task } => match (text, state) {
973 (Some(note), Some(s)) => {
974 let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
975 if noted.status() == CommandStatus::Ok {
976 run_task(root, feature, task, options)
977 } else {
978 noted
979 }
980 }
981 (None, _) => needs("a hint"),
982 (_, None) => no_state(),
983 },
984 Runnable::Rule { task } => match (text, state) {
985 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
986 (None, _) => needs("a reason"),
987 (_, None) => no_state(),
988 },
989 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
990 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
991 ("edit" | "edit-approve", Some(id), Some(t)) => {
992 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
993 }
994 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
995 _ => crate::review::decide(root, feature, ids, verdict, text, via),
996 },
997 Runnable::IssueComment { issue, assign } => crate::github::comment(
998 &crate::github::Ctx::from_env(),
999 root,
1000 feature,
1001 &issue.to_string(),
1002 *assign,
1003 ),
1004 Runnable::QuickChange {
1005 request,
1006 checks,
1007 run,
1008 asked,
1009 } => quick_change(
1010 root,
1011 request,
1012 checks,
1013 *run,
1014 via,
1015 options,
1016 asked.as_deref().zip(state),
1017 ),
1018 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
1019 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
1020 Runnable::FixSetup => crate::code::fix_setup(root, feature),
1021 Runnable::Feature => match text {
1022 Some(name) => crate::current::switch(root, name),
1023 None => needs("a feature"),
1024 },
1025 Runnable::BugFix {
1026 symptom,
1027 expected,
1028 unchanged,
1029 issue,
1030 tests,
1031 candidate,
1032 slug,
1033 run,
1034 asked,
1035 } => bug_fix(
1036 root,
1037 &crate::bug::Request {
1038 asked: asked.clone(),
1039 ..crate::bug::Request::of(
1040 symptom,
1041 expected,
1042 unchanged,
1043 *issue,
1044 tests,
1045 candidate.as_deref(),
1046 slug.as_deref(),
1047 *run,
1048 )
1049 },
1050 via,
1051 state,
1052 options,
1053 ),
1054 Runnable::BugConfirm {
1055 task,
1056 digest,
1057 reject,
1058 } => match (*reject, text) {
1059 (true, None) => needs("a reason"),
1060 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1061 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1062 },
1063 Runnable::BugRun { slug, task } => {
1064 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1065 }
1066 Runnable::BugKeep { task } => match text {
1067 Some(why) => crate::bug::keep(root, task, why, state),
1068 None => needs("a reason"),
1069 },
1070 Runnable::AllowAlways { task, call } => {
1071 crate::approvals::allow_always(root, feature, task, call, state, via)
1072 }
1073 Runnable::WidenPolicy {
1074 key,
1075 prefix,
1076 digest,
1077 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1078 Runnable::DecideBatch {
1079 run,
1080 verdict,
1081 calls,
1082 digest,
1083 resume_asking,
1084 } => {
1085 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1086 if !approve && text.is_none() {
1087 return needs("a reason");
1088 }
1089 crate::approvals::decide_batch(
1090 root,
1091 feature,
1092 crate::approvals::BatchChoice {
1093 run,
1094 approve,
1095 calls,
1096 digest,
1097 resume_asking: *resume_asking,
1098 reason: text,
1099 },
1100 state,
1101 via,
1102 )
1103 }
1104 Runnable::CheckStory { story } => {
1105 crate::pipeline::story::check_story(root, feature, story, options)
1106 }
1107 Runnable::OracleTests {
1108 req,
1109 op,
1110 path,
1111 scenario,
1112 reading,
1113 } => crate::oracle::tests(
1114 root,
1115 feature,
1116 &crate::oracle::Request {
1117 req,
1118 op: *op,
1119 path: path.as_deref(),
1120 scenario: scenario.as_deref(),
1121 reading: *reading,
1122 text: d.input.filter(|t| !t.trim().is_empty()),
1123 },
1124 via,
1125 state,
1126 options,
1127 ),
1128 Runnable::ApproveAndRun {
1129 mode,
1130 grants,
1131 sheet,
1132 } => crate::review::approve_and_run(
1133 root,
1134 feature,
1135 *mode,
1136 grants,
1137 sheet.as_deref(),
1138 via,
1139 state,
1140 None,
1141 options,
1142 ),
1143 Runnable::Comment { op, anchor, id } => crate::comments::comment(
1144 root,
1145 feature,
1146 *op,
1147 anchor.as_ref(),
1148 id.as_deref(),
1149 text,
1150 via,
1151 state,
1152 ),
1153 Runnable::CommentsSend { ids } => {
1154 crate::comments::send(root, feature, ids, via, state, options)
1155 }
1156 Runnable::Explore {
1157 idea,
1158 skill,
1159 resume,
1160 } => crate::explore::explore(
1161 root,
1162 text.unwrap_or(idea),
1163 skill.as_deref(),
1164 resume.as_deref(),
1165 options,
1166 ),
1167 Runnable::ExploreChoose {
1168 session,
1169 approach,
1170 constraints,
1171 questions,
1172 flag,
1173 } => crate::explore::choose(
1174 root,
1175 state,
1176 session,
1177 *approach,
1178 constraints,
1179 questions,
1180 via,
1181 if *flag { "flag" } else { "prompt" },
1182 ),
1183 Runnable::RevisionUndo { turn } => {
1184 crate::comments::undo(root, feature, turn.as_deref(), state)
1185 }
1186 Runnable::CodeNudge { task, comments } => {
1187 crate::comments::code_nudge(root, feature, task, comments, state)
1188 }
1189 Runnable::PlanThrough {
1192 from:
1193 speccoder_core::actions::PlanFrom::Create {
1194 description,
1195 source,
1196 },
1197 through,
1198 } => {
1199 let description = if description.trim().is_empty() {
1200 text.unwrap_or_default()
1201 } else {
1202 description.as_str()
1203 };
1204 match source {
1205 speccoder_core::actions::FeatureSource::Promotion {
1208 task,
1209 conversations,
1210 } => {
1211 let Some(s) = state else { return no_state() };
1212 let input = crate::ladder::promotion_input(root, s, description, conversations);
1213 let made = plan_through(root, &input, source, *through, options);
1214 crate::ladder::promoted(
1215 root,
1216 s,
1217 &made,
1218 task,
1219 conversations,
1220 via,
1221 options.progress.as_ref(),
1222 );
1223 made
1224 }
1225 speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1228 let Some(s) = state else { return no_state() };
1229 if let Err(refused) =
1230 crate::explore::verify_start(root, s, session, digest, description)
1231 {
1232 return *refused;
1233 }
1234 let made = plan_through(root, description, source, *through, options);
1235 crate::explore::explored(root, s, &made, session, digest, via);
1236 made
1237 }
1238 _ => plan_through(root, description, source, *through, options),
1239 }
1240 }
1241 Runnable::PlanThrough {
1242 from: speccoder_core::actions::PlanFrom::Continue,
1243 through,
1244 } => crate::planning_chain::resume(root, feature, *through, options),
1245 Runnable::NewProject {
1247 target,
1248 choices,
1249 plan_digest,
1250 } => match state {
1251 Some(s) => crate::new_project::perform(
1252 Path::new(target),
1253 choices,
1254 plan_digest,
1255 via,
1256 s,
1257 options,
1258 ),
1259 None => no_state(),
1260 },
1261 Runnable::UndoNewProject { commit } => match state {
1262 Some(s) => crate::new_project::undo(root, commit, via, s, options),
1263 None => no_state(),
1264 },
1265 Runnable::CloneRepository { repo, target } => {
1267 crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1268 }
1269 Runnable::OpenSample { fresh } => match state {
1270 Some(s) => crate::sample::open(s, *fresh),
1271 None => no_state(),
1272 },
1273 Runnable::SampleChecks => match state {
1274 Some(s) => crate::sample::run_checks(s, root),
1275 None => no_state(),
1276 },
1277 Runnable::DeclinePromotion { task } => match state {
1278 Some(s) => crate::ladder::decline_task(root, s, task),
1279 None => no_state(),
1280 },
1281 Runnable::PullRequest => crate::handoff::pr(
1284 root,
1285 feature,
1286 &crate::handoff::PrOptions {
1287 remote: "origin",
1288 base: None,
1289 open: true,
1290 force: false,
1291 github: &crate::github::Ctx::from_env(),
1292 },
1293 ),
1294 }
1295}
1296
1297#[must_use]
1301pub fn allow_always_inline(
1302 root: &Path,
1303 feature: &str,
1304 always: &[crate::substrate::Always],
1305 state: &Path,
1306) -> Vec<String> {
1307 always
1308 .iter()
1309 .map(|a| {
1310 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1311 let run = Runnable::AllowAlways {
1312 task,
1313 call: a.call.clone(),
1314 };
1315 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1316 .message()
1317 .to_owned()
1318 })
1319 .collect()
1320}
1321
1322#[must_use]
1328pub fn answered_at_stop(
1329 root: &Path,
1330 feature: Option<&str>,
1331 task: &str,
1332 answer: &crate::approvals::StopAnswer,
1333 (via, run): (Via, &RunOptions),
1334 state: &Path,
1335) -> CommandResult {
1336 use crate::approvals::StopAnswer;
1337 let task = task.to_owned();
1338 let (what, input) = match answer {
1339 StopAnswer::Done => (Runnable::Done { task }, None),
1340 StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1341 StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1342 };
1343 perform_in(
1344 root,
1345 feature,
1346 Dispatch {
1347 input,
1348 options: RunOptions {
1349 in_place: run.in_place,
1350 session: run.session.clone(),
1351 ..RunOptions::default()
1352 },
1353 ..Dispatch::new(&what, via)
1354 },
1355 Some(state),
1356 )
1357}
1358
1359fn skill(
1364 root: &Path,
1365 feature: Option<&str>,
1366 name: &str,
1367 input: Option<&str>,
1368 carried: &str,
1369 options: &RunOptions,
1370) -> CommandResult {
1371 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1372}
1373
1374fn plan_through(
1375 root: &Path,
1376 description: &str,
1377 source: &speccoder_core::actions::FeatureSource,
1378 through: speccoder_core::actions::Through,
1379 options: &RunOptions,
1380) -> CommandResult {
1381 crate::planning_chain::start(root, description, source, through, options)
1382}
1383
1384fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1385 crate::run(root, feature, task, options)
1386}
1387
1388fn run_feature(
1389 root: &Path,
1390 feature: Option<&str>,
1391 options: &RunOptions,
1392 converge: bool,
1393) -> CommandResult {
1394 crate::run_feature(root, feature, options, converge)
1395}
1396
1397fn bug_fix(
1398 root: &Path,
1399 req: &crate::bug::Request,
1400 via: Via,
1401 state: Option<&Path>,
1402 options: &RunOptions,
1403) -> CommandResult {
1404 crate::bug::bug(root, req, via, state, options)
1405}
1406
1407fn bug_confirm(
1408 root: &Path,
1409 task: &str,
1410 digest: &str,
1411 reject: Option<&str>,
1412 via: Via,
1413 state: Option<&Path>,
1414 options: &RunOptions,
1415) -> CommandResult {
1416 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1417}
1418
1419fn bug_run(
1420 root: &Path,
1421 slug: Option<&str>,
1422 task: Option<&str>,
1423 state: Option<&Path>,
1424 options: &RunOptions,
1425) -> CommandResult {
1426 crate::bug::run(root, slug, task, state, options)
1427}
1428
1429fn quick_change(
1430 root: &Path,
1431 request: &str,
1432 checks: &[String],
1433 run: bool,
1434 via: Via,
1435 options: &RunOptions,
1436 asked: Option<(&str, &Path)>,
1437) -> CommandResult {
1438 crate::quick::change(root, request, checks, run, via, options, asked)
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use speccoder_core::actions::primary;
1444
1445 use super::*;
1446
1447 #[test]
1448 fn every_interface_decides_every_kind() {
1449 let mut taken = 0;
1450 for i in Interface::ALL {
1451 for k in ActionKind::ALL {
1452 if offered(*i, *k) == Offer::Take {
1453 taken += 1;
1454 } else {
1455 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1456 }
1457 }
1458 }
1459 let runnable = Runnable::examples().len();
1463 assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1466 assert_eq!(
1467 offered(Interface::CliFollow, ActionKind::PullRequest),
1468 Offer::Refuse
1469 );
1470 assert_eq!(
1471 offered(Interface::AcpRemedy, ActionKind::FixSetup),
1472 Offer::Take
1473 );
1474 }
1475
1476 fn copied(line: &str) -> bool {
1479 let mut w = line.split_whitespace().skip(1);
1480 matches!(w.next(), Some("eval" | "replay" | "bench"))
1481 || line.contains("--remote <")
1482 || line.split_whitespace().any(|w| w == "-C")
1484 || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1486 && line.split_whitespace().count() == 3
1487 }
1488
1489 const PROSE: &[&str] = &[
1493 "speccoder did not ",
1494 "speccoder cannot ",
1495 "speccoder commits ",
1496 "speccoder could ",
1497 "speccoder failed ",
1498 "speccoder has ",
1499 "speccoder is ",
1500 "speccoder needs ",
1501 "speccoder reads ",
1502 "speccoder works ",
1503 "speccoder does not ",
1504 "speccoder implements ",
1505 "speccoder converges ",
1506 "speccoder runs ",
1507 "speccoder Documentation",
1508 "speccoder — ",
1509 "speccoder ✓",
1510 "speccoder ■",
1511 "speccoder ▸",
1512 "speccoder △",
1513 "speccoder ✗",
1514 "speccoder / ",
1515 "speccoder attempt: ",
1516 "speccoder manifest: ",
1517 "speccoder inspect: ",
1518 "speccoder preset ",
1519 "speccoder bundles",
1520 "speccoder {}: ",
1521 "speccoder {feature}: ",
1522 "speccoder 001-x: ",
1523 "speccoder {} · ",
1524 "speccoder {} --yes",
1526 ];
1527
1528 #[test]
1534 fn no_speccoder_command_is_copy_only() {
1535 let ctx = Context {
1536 feature: Some("001-x"),
1537 task: Some("T001"),
1538 };
1539 let mut seen = 0;
1540 let mut errors = Vec::new();
1541 for e in speccoder_core::codes::CATALOG {
1542 for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1543 if line.starts_with("speccoder ") && !copied(&line) {
1544 let how = runnable_of(&line, ctx).how;
1545 if how.copy_only() {
1546 errors.push(format!("{}: {line} → {how:?}", e.name));
1547 }
1548 seen += 1;
1549 }
1550 }
1551 }
1552 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1553 let mut files = Vec::new();
1554 for dir in [
1555 "src",
1556 "../speccoder-core/src",
1557 "../speccoder-web/src",
1558 "../speccoder/src",
1559 ] {
1560 let mut stack = vec![root.join(dir)];
1561 while let Some(d) = stack.pop() {
1562 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1563 let p = entry.path();
1564 if p.is_dir() {
1565 stack.push(p);
1566 } else if p.extension().is_some_and(|x| x == "rs") {
1567 files.push(p);
1568 }
1569 }
1570 }
1571 }
1572 assert!(files.len() > 100, "{} files", files.len());
1573 for f in files {
1574 let src = std::fs::read_to_string(&f).unwrap_or_default();
1575 let src = src
1577 .split("#[cfg(test)]\nmod tests")
1578 .next()
1579 .unwrap_or_default();
1580 for (at, _) in src.match_indices("\"speccoder ") {
1581 if src[..at].ends_with('\\') {
1583 continue;
1584 }
1585 let mut line = String::new();
1586 let mut chars = src[at + 1..].chars();
1587 while let Some(c) = chars.next() {
1588 match c {
1589 '\\' => line.extend(chars.next()),
1590 '"' => break,
1591 c => line.push(c),
1592 }
1593 }
1594 let mut filled = String::new();
1596 let mut rest = line.as_str();
1597 while let Some(open) = rest.find('{') {
1598 filled.push_str(&rest[..open]);
1599 let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1600 let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1602 filled.push_str(match &rest[open..close] {
1603 _ if suffix => "",
1604 "{task}" | "{t}" => "T001",
1605 "{feature}" | "{f}" => "001-x",
1606 "{command}" | "{verb}" => "run",
1607 _ if filled.ends_with("--through ") => "tasks",
1608 _ => "x",
1609 });
1610 rest = &rest[close.min(rest.len())..];
1611 }
1612 filled.push_str(rest);
1613 if line == "speccoder "
1615 || copied(&filled)
1616 || PROSE.iter().any(|p| line.starts_with(p))
1617 {
1618 continue;
1619 }
1620 let how = runnable_of(&filled, ctx).how;
1621 if how.copy_only() {
1622 errors.push(format!("{}: {filled} → {how:?}", f.display()));
1623 }
1624 seen += 1;
1625 }
1626 }
1627 assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1628 assert!(seen > 100, "{seen} lines seen");
1629 }
1630
1631 #[test]
1632 fn blank_input_is_no_input() {
1633 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1634 let run = Runnable::Reject {
1635 task: "T001".to_owned(),
1636 call: "attempt:T001".to_owned(),
1637 digest: String::new(),
1638 };
1639 let r = perform_in(
1640 dir.path(),
1641 None,
1642 Dispatch {
1643 run: &run,
1644 input: Some(" "),
1645 via: Via::Web,
1646 options: RunOptions::default(),
1647 },
1648 None,
1649 );
1650 assert_eq!(
1651 (r.command(), r.status(), r.message()),
1652 ("job", CommandStatus::UsageError, "a reason is needed")
1653 );
1654 }
1655
1656 #[test]
1657 fn a_project_outside_git_still_gets_a_list() {
1658 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1659 let list = available(dir.path(), None, None);
1660 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1661 assert!(primary(&list).is_none());
1662 }
1663}