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