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