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