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::DeferAcceptance
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::DeferAcceptance
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::DeferAcceptance
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::DeferAcceptance
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::DeferAcceptance
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::DeferAcceptance { task, revoke } => match state {
913 Some(s) => crate::acceptance_at::defer(root, feature, task, *revoke, via, s),
914 None => no_state(),
915 },
916 Runnable::PresetAdd { update } => match state {
918 Some(s) => crate::preset::add(root, *update, true, s),
919 None => no_state(),
920 },
921 Runnable::Skip { task } => match text {
922 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
923 None => needs("a reason"),
924 },
925 Runnable::Nudge { task } => match (text, state) {
926 (Some(note), Some(s)) => {
927 crate::controls::nudge_with(root, feature, task, Some(note), s)
928 }
929 (None, _) => needs("a note"),
930 (_, None) => no_state(),
931 },
932 Runnable::Done { task } => match state {
935 Some(s) => crate::controls::accept_in_session(
936 root,
937 feature,
938 task,
939 text.unwrap_or(DONE_REASON),
940 s,
941 options,
942 ),
943 None => no_state(),
944 },
945 Runnable::Hint { task } => match (text, state) {
947 (Some(note), Some(s)) => {
948 let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
949 if noted.status() == CommandStatus::Ok {
950 run_task(root, feature, task, options)
951 } else {
952 noted
953 }
954 }
955 (None, _) => needs("a hint"),
956 (_, None) => no_state(),
957 },
958 Runnable::Rule { task } => match (text, state) {
959 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
960 (None, _) => needs("a reason"),
961 (_, None) => no_state(),
962 },
963 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
964 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
965 ("edit" | "edit-approve", Some(id), Some(t)) => {
966 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
967 }
968 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
969 _ => crate::review::decide(root, feature, ids, verdict, text, via),
970 },
971 Runnable::IssueComment { issue, assign } => crate::github::comment(
972 &crate::github::Ctx::from_env(),
973 root,
974 feature,
975 &issue.to_string(),
976 *assign,
977 ),
978 Runnable::QuickChange {
979 request,
980 checks,
981 run,
982 asked,
983 } => quick_change(
984 root,
985 request,
986 checks,
987 *run,
988 via,
989 options,
990 asked.as_deref().zip(state),
991 ),
992 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
993 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
994 Runnable::FixSetup => crate::code::fix_setup(root, feature),
995 Runnable::Feature => match text {
996 Some(name) => crate::current::switch(root, name),
997 None => needs("a feature"),
998 },
999 Runnable::BugFix {
1000 symptom,
1001 expected,
1002 unchanged,
1003 issue,
1004 tests,
1005 candidate,
1006 slug,
1007 run,
1008 asked,
1009 } => bug_fix(
1010 root,
1011 &crate::bug::Request {
1012 asked: asked.clone(),
1013 ..crate::bug::Request::of(
1014 symptom,
1015 expected,
1016 unchanged,
1017 *issue,
1018 tests,
1019 candidate.as_deref(),
1020 slug.as_deref(),
1021 *run,
1022 )
1023 },
1024 via,
1025 state,
1026 options,
1027 ),
1028 Runnable::BugConfirm {
1029 task,
1030 digest,
1031 reject,
1032 } => match (*reject, text) {
1033 (true, None) => needs("a reason"),
1034 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1035 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1036 },
1037 Runnable::BugRun { slug, task } => {
1038 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1039 }
1040 Runnable::BugKeep { task } => match text {
1041 Some(why) => crate::bug::keep(root, task, why, state),
1042 None => needs("a reason"),
1043 },
1044 Runnable::AllowAlways { task, call } => {
1045 crate::approvals::allow_always(root, feature, task, call, state, via)
1046 }
1047 Runnable::WidenPolicy {
1048 key,
1049 prefix,
1050 digest,
1051 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1052 Runnable::DecideBatch {
1053 run,
1054 verdict,
1055 calls,
1056 digest,
1057 resume_asking,
1058 } => {
1059 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1060 if !approve && text.is_none() {
1061 return needs("a reason");
1062 }
1063 crate::approvals::decide_batch(
1064 root,
1065 feature,
1066 crate::approvals::BatchChoice {
1067 run,
1068 approve,
1069 calls,
1070 digest,
1071 resume_asking: *resume_asking,
1072 reason: text,
1073 },
1074 state,
1075 via,
1076 )
1077 }
1078 Runnable::OracleTests {
1079 req,
1080 op,
1081 path,
1082 scenario,
1083 reading,
1084 } => crate::oracle::tests(
1085 root,
1086 feature,
1087 &crate::oracle::Request {
1088 req,
1089 op: *op,
1090 path: path.as_deref(),
1091 scenario: scenario.as_deref(),
1092 reading: *reading,
1093 text: d.input.filter(|t| !t.trim().is_empty()),
1094 },
1095 via,
1096 state,
1097 options,
1098 ),
1099 Runnable::ApproveAndRun {
1100 mode,
1101 grants,
1102 sheet,
1103 } => crate::review::approve_and_run(
1104 root,
1105 feature,
1106 *mode,
1107 grants,
1108 sheet.as_deref(),
1109 via,
1110 state,
1111 None,
1112 options,
1113 ),
1114 Runnable::Comment { op, anchor, id } => crate::comments::comment(
1115 root,
1116 feature,
1117 *op,
1118 anchor.as_ref(),
1119 id.as_deref(),
1120 text,
1121 via,
1122 state,
1123 ),
1124 Runnable::CommentsSend { ids } => {
1125 crate::comments::send(root, feature, ids, via, state, options)
1126 }
1127 Runnable::Explore {
1128 idea,
1129 skill,
1130 resume,
1131 } => crate::explore::explore(
1132 root,
1133 text.unwrap_or(idea),
1134 skill.as_deref(),
1135 resume.as_deref(),
1136 options,
1137 ),
1138 Runnable::ExploreChoose {
1139 session,
1140 approach,
1141 constraints,
1142 questions,
1143 flag,
1144 } => crate::explore::choose(
1145 root,
1146 state,
1147 session,
1148 *approach,
1149 constraints,
1150 questions,
1151 via,
1152 if *flag { "flag" } else { "prompt" },
1153 ),
1154 Runnable::RevisionUndo { turn } => {
1155 crate::comments::undo(root, feature, turn.as_deref(), state)
1156 }
1157 Runnable::CodeNudge { task, comments } => {
1158 crate::comments::code_nudge(root, feature, task, comments, state)
1159 }
1160 Runnable::PlanThrough {
1163 from:
1164 speccoder_core::actions::PlanFrom::Create {
1165 description,
1166 source,
1167 },
1168 through,
1169 } => {
1170 let description = if description.trim().is_empty() {
1171 text.unwrap_or_default()
1172 } else {
1173 description.as_str()
1174 };
1175 match source {
1176 speccoder_core::actions::FeatureSource::Promotion {
1179 task,
1180 conversations,
1181 } => {
1182 let Some(s) = state else { return no_state() };
1183 let input = crate::ladder::promotion_input(root, s, description, conversations);
1184 let made = plan_through(root, &input, source, *through, options);
1185 crate::ladder::promoted(
1186 root,
1187 s,
1188 &made,
1189 task,
1190 conversations,
1191 via,
1192 options.progress.as_ref(),
1193 );
1194 made
1195 }
1196 speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1199 let Some(s) = state else { return no_state() };
1200 if let Err(refused) =
1201 crate::explore::verify_start(root, s, session, digest, description)
1202 {
1203 return *refused;
1204 }
1205 let made = plan_through(root, description, source, *through, options);
1206 crate::explore::explored(root, s, &made, session, digest, via);
1207 made
1208 }
1209 _ => plan_through(root, description, source, *through, options),
1210 }
1211 }
1212 Runnable::PlanThrough {
1213 from: speccoder_core::actions::PlanFrom::Continue,
1214 through,
1215 } => crate::planning_chain::resume(root, feature, *through, options),
1216 Runnable::NewProject {
1218 target,
1219 choices,
1220 plan_digest,
1221 } => match state {
1222 Some(s) => crate::new_project::perform(
1223 Path::new(target),
1224 choices,
1225 plan_digest,
1226 via,
1227 s,
1228 options,
1229 ),
1230 None => no_state(),
1231 },
1232 Runnable::UndoNewProject { commit } => match state {
1233 Some(s) => crate::new_project::undo(root, commit, via, s, options),
1234 None => no_state(),
1235 },
1236 Runnable::CloneRepository { repo, target } => {
1238 crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1239 }
1240 Runnable::OpenSample { fresh } => match state {
1241 Some(s) => crate::sample::open(s, *fresh),
1242 None => no_state(),
1243 },
1244 Runnable::SampleChecks => match state {
1245 Some(s) => crate::sample::run_checks(s, root),
1246 None => no_state(),
1247 },
1248 Runnable::DeclinePromotion { task } => match state {
1249 Some(s) => crate::ladder::decline_task(root, s, task),
1250 None => no_state(),
1251 },
1252 Runnable::PullRequest => crate::handoff::pr(
1255 root,
1256 feature,
1257 &crate::handoff::PrOptions {
1258 remote: "origin",
1259 base: None,
1260 open: true,
1261 force: false,
1262 github: &crate::github::Ctx::from_env(),
1263 },
1264 ),
1265 }
1266}
1267
1268#[must_use]
1272pub fn allow_always_inline(
1273 root: &Path,
1274 feature: &str,
1275 always: &[crate::substrate::Always],
1276 state: &Path,
1277) -> Vec<String> {
1278 always
1279 .iter()
1280 .map(|a| {
1281 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1282 let run = Runnable::AllowAlways {
1283 task,
1284 call: a.call.clone(),
1285 };
1286 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1287 .message()
1288 .to_owned()
1289 })
1290 .collect()
1291}
1292
1293#[must_use]
1299pub fn answered_at_stop(
1300 root: &Path,
1301 feature: Option<&str>,
1302 task: &str,
1303 answer: &crate::approvals::StopAnswer,
1304 (via, run): (Via, &RunOptions),
1305 state: &Path,
1306) -> CommandResult {
1307 use crate::approvals::StopAnswer;
1308 let task = task.to_owned();
1309 let (what, input) = match answer {
1310 StopAnswer::Done => (Runnable::Done { task }, None),
1311 StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1312 StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1313 };
1314 perform_in(
1315 root,
1316 feature,
1317 Dispatch {
1318 input,
1319 options: RunOptions {
1320 in_place: run.in_place,
1321 session: run.session.clone(),
1322 ..RunOptions::default()
1323 },
1324 ..Dispatch::new(&what, via)
1325 },
1326 Some(state),
1327 )
1328}
1329
1330fn skill(
1335 root: &Path,
1336 feature: Option<&str>,
1337 name: &str,
1338 input: Option<&str>,
1339 carried: &str,
1340 options: &RunOptions,
1341) -> CommandResult {
1342 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1343}
1344
1345fn plan_through(
1346 root: &Path,
1347 description: &str,
1348 source: &speccoder_core::actions::FeatureSource,
1349 through: speccoder_core::actions::Through,
1350 options: &RunOptions,
1351) -> CommandResult {
1352 crate::planning_chain::start(root, description, source, through, options)
1353}
1354
1355fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1356 crate::run(root, feature, task, options)
1357}
1358
1359fn run_feature(
1360 root: &Path,
1361 feature: Option<&str>,
1362 options: &RunOptions,
1363 converge: bool,
1364) -> CommandResult {
1365 crate::run_feature(root, feature, options, converge)
1366}
1367
1368fn bug_fix(
1369 root: &Path,
1370 req: &crate::bug::Request,
1371 via: Via,
1372 state: Option<&Path>,
1373 options: &RunOptions,
1374) -> CommandResult {
1375 crate::bug::bug(root, req, via, state, options)
1376}
1377
1378fn bug_confirm(
1379 root: &Path,
1380 task: &str,
1381 digest: &str,
1382 reject: Option<&str>,
1383 via: Via,
1384 state: Option<&Path>,
1385 options: &RunOptions,
1386) -> CommandResult {
1387 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1388}
1389
1390fn bug_run(
1391 root: &Path,
1392 slug: Option<&str>,
1393 task: Option<&str>,
1394 state: Option<&Path>,
1395 options: &RunOptions,
1396) -> CommandResult {
1397 crate::bug::run(root, slug, task, state, options)
1398}
1399
1400fn quick_change(
1401 root: &Path,
1402 request: &str,
1403 checks: &[String],
1404 run: bool,
1405 via: Via,
1406 options: &RunOptions,
1407 asked: Option<(&str, &Path)>,
1408) -> CommandResult {
1409 crate::quick::change(root, request, checks, run, via, options, asked)
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use speccoder_core::actions::primary;
1415
1416 use super::*;
1417
1418 #[test]
1419 fn every_interface_decides_every_kind() {
1420 let mut taken = 0;
1421 for i in Interface::ALL {
1422 for k in ActionKind::ALL {
1423 if offered(*i, *k) == Offer::Take {
1424 taken += 1;
1425 } else {
1426 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1427 }
1428 }
1429 }
1430 let runnable = Runnable::examples().len();
1434 assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1437 assert_eq!(
1438 offered(Interface::CliFollow, ActionKind::PullRequest),
1439 Offer::Refuse
1440 );
1441 assert_eq!(
1442 offered(Interface::AcpRemedy, ActionKind::FixSetup),
1443 Offer::Take
1444 );
1445 }
1446
1447 fn copied(line: &str) -> bool {
1450 let mut w = line.split_whitespace().skip(1);
1451 matches!(w.next(), Some("eval" | "replay" | "bench"))
1452 || line.contains("--remote <")
1453 || line.split_whitespace().any(|w| w == "-C")
1455 || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1457 && line.split_whitespace().count() == 3
1458 }
1459
1460 const PROSE: &[&str] = &[
1464 "speccoder did not ",
1465 "speccoder cannot ",
1466 "speccoder commits ",
1467 "speccoder could ",
1468 "speccoder failed ",
1469 "speccoder has ",
1470 "speccoder is ",
1471 "speccoder needs ",
1472 "speccoder reads ",
1473 "speccoder works ",
1474 "speccoder does not ",
1475 "speccoder implements ",
1476 "speccoder converges ",
1477 "speccoder runs ",
1478 "speccoder Documentation",
1479 "speccoder — ",
1480 "speccoder ✓",
1481 "speccoder ■",
1482 "speccoder ▸",
1483 "speccoder △",
1484 "speccoder ✗",
1485 "speccoder / ",
1486 "speccoder attempt: ",
1487 "speccoder manifest: ",
1488 "speccoder inspect: ",
1489 "speccoder preset ",
1490 "speccoder bundles",
1491 "speccoder {}: ",
1492 "speccoder {feature}: ",
1493 "speccoder 001-x: ",
1494 "speccoder {} · ",
1495 "speccoder {} --yes",
1497 ];
1498
1499 #[test]
1505 fn no_speccoder_command_is_copy_only() {
1506 let ctx = Context {
1507 feature: Some("001-x"),
1508 task: Some("T001"),
1509 };
1510 let mut seen = 0;
1511 let mut errors = Vec::new();
1512 for e in speccoder_core::codes::CATALOG {
1513 for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1514 if line.starts_with("speccoder ") && !copied(&line) {
1515 let how = runnable_of(&line, ctx).how;
1516 if how.copy_only() {
1517 errors.push(format!("{}: {line} → {how:?}", e.name));
1518 }
1519 seen += 1;
1520 }
1521 }
1522 }
1523 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1524 let mut files = Vec::new();
1525 for dir in [
1526 "src",
1527 "../speccoder-core/src",
1528 "../speccoder-web/src",
1529 "../speccoder/src",
1530 ] {
1531 let mut stack = vec![root.join(dir)];
1532 while let Some(d) = stack.pop() {
1533 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1534 let p = entry.path();
1535 if p.is_dir() {
1536 stack.push(p);
1537 } else if p.extension().is_some_and(|x| x == "rs") {
1538 files.push(p);
1539 }
1540 }
1541 }
1542 }
1543 assert!(files.len() > 100, "{} files", files.len());
1544 for f in files {
1545 let src = std::fs::read_to_string(&f).unwrap_or_default();
1546 let src = src
1548 .split("#[cfg(test)]\nmod tests")
1549 .next()
1550 .unwrap_or_default();
1551 for (at, _) in src.match_indices("\"speccoder ") {
1552 if src[..at].ends_with('\\') {
1554 continue;
1555 }
1556 let mut line = String::new();
1557 let mut chars = src[at + 1..].chars();
1558 while let Some(c) = chars.next() {
1559 match c {
1560 '\\' => line.extend(chars.next()),
1561 '"' => break,
1562 c => line.push(c),
1563 }
1564 }
1565 let mut filled = String::new();
1567 let mut rest = line.as_str();
1568 while let Some(open) = rest.find('{') {
1569 filled.push_str(&rest[..open]);
1570 let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1571 let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1573 filled.push_str(match &rest[open..close] {
1574 _ if suffix => "",
1575 "{task}" | "{t}" => "T001",
1576 "{feature}" | "{f}" => "001-x",
1577 "{command}" | "{verb}" => "run",
1578 _ if filled.ends_with("--through ") => "tasks",
1579 _ => "x",
1580 });
1581 rest = &rest[close.min(rest.len())..];
1582 }
1583 filled.push_str(rest);
1584 if line == "speccoder "
1586 || copied(&filled)
1587 || PROSE.iter().any(|p| line.starts_with(p))
1588 {
1589 continue;
1590 }
1591 let how = runnable_of(&filled, ctx).how;
1592 if how.copy_only() {
1593 errors.push(format!("{}: {filled} → {how:?}", f.display()));
1594 }
1595 seen += 1;
1596 }
1597 }
1598 assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1599 assert!(seen > 100, "{seen} lines seen");
1600 }
1601
1602 #[test]
1603 fn blank_input_is_no_input() {
1604 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1605 let run = Runnable::Reject {
1606 task: "T001".to_owned(),
1607 call: "attempt:T001".to_owned(),
1608 digest: String::new(),
1609 };
1610 let r = perform_in(
1611 dir.path(),
1612 None,
1613 Dispatch {
1614 run: &run,
1615 input: Some(" "),
1616 via: Via::Web,
1617 options: RunOptions::default(),
1618 },
1619 None,
1620 );
1621 assert_eq!(
1622 (r.command(), r.status(), r.message()),
1623 ("job", CommandStatus::UsageError, "a reason is needed")
1624 );
1625 }
1626
1627 #[test]
1628 fn a_project_outside_git_still_gets_a_list() {
1629 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1630 let list = available(dir.path(), None, None);
1631 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1632 assert!(primary(&list).is_none());
1633 }
1634}