1use std::path::Path;
36
37use speccoder_core::actions::{Action, ActionKind, Runnable, Via};
38use speccoder_core::{ApprovalsReport, CommandData, CommandResult, CommandStatus, StatusReport};
39
40use crate::RunOptions;
41
42pub use speccoder_core::step::{Context, How, Step, View, step_of as runnable_of};
47
48pub(crate) fn action(
49 group: &str,
50 label: impl Into<String>,
51 why: impl Into<String>,
52 command: impl Into<String>,
53) -> Action {
54 Action::new(group, label, why, command)
55}
56
57pub const DONE_REASON: &str = "I did it";
59
60#[must_use]
63pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
64 available_with(path, feature, selected, None)
65}
66
67#[must_use]
70pub fn available_with(
71 path: &Path,
72 feature: Option<&str>,
73 selected: Option<&str>,
74 listed: Option<&ApprovalsReport>,
75) -> Vec<Action> {
76 let report = crate::status::report(path, feature);
77 let report: Option<&StatusReport> = match report.data() {
78 Some(CommandData::Report(r)) => Some(r),
79 _ => None,
80 };
81 let approvals = listed.cloned().or_else(|| {
82 crate::worktree::state_dir().and_then(|state| {
83 match crate::approvals::list_with(path, feature, &state).data() {
84 Some(CommandData::Approvals(a)) => Some((**a).clone()),
85 _ => None,
86 }
87 })
88 });
89 let uncommitted = crate::hosting::uncommitted(path, feature);
90 let skills = match crate::hosting::list(path).data() {
91 Some(CommandData::Skills(s)) => s.skills.clone(),
92 _ => Vec::new(),
93 };
94 let format = crate::format::at(path);
95 let planning = format.planning();
96 let unit = format.messages().unit;
97 let features = crate::current::list(path, None);
98 let feature_count = match features.data() {
99 Some(CommandData::Features(f)) => f.features.len(),
100 _ => 0,
101 };
102 let short = |name: &str| {
103 name.strip_prefix(planning.prefix)
104 .unwrap_or(name)
105 .to_owned()
106 };
107 let skill = |group: &str, name: &str, why: &str| {
108 let creates = planning.creates.contains(&name);
109 let mut a = action(
110 group,
111 format!("spec {name}"),
112 why,
113 if creates {
114 format!("speccoder spec {name} \"…\"")
115 } else {
116 format!("speccoder spec {name}")
117 },
118 );
119 a = a
120 .with_run(Runnable::Skill {
121 name: name.to_owned(),
122 arguments: String::new(),
123 })
124 .with_role(planning.role_of(name));
125 if creates {
126 a.input = Some(format!("Describe the {unit}"));
127 }
128 a
129 };
130 let mut out = Vec::new();
131
132 for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
134 let mut a = action(
135 "next",
136 format!("approve {}'s waiting call", p.task),
137 format!("{} — {}", p.covers, p.reason),
138 format!("speccoder task approve {}", p.task),
139 );
140 a = a.with_run(Runnable::Approve {
141 task: p.task.clone(),
142 call: p.id(),
143 digest: p.digest.clone(),
144 });
145 out.push(a);
146 let mut r = action(
147 "next",
148 format!("reject {}'s waiting call", p.task),
149 "the model is told your reason",
150 format!("speccoder task reject {} --reason \"…\"", p.task),
151 );
152 r = r.with_run(Runnable::Reject {
153 task: p.task.clone(),
154 call: p.id(),
155 digest: p.digest.clone(),
156 });
157 r.input = Some("Why (the model is told)".to_owned());
158 out.push(r);
159 }
160 let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
161 if let Some(first) = planning_next.first() {
164 let mut a = action(
165 "next",
166 "plan through to tasks",
167 "the format's own skills in order; it stops for open questions and your review",
168 format!("speccoder spec {first} --through tasks"),
169 );
170 a = a.with_run(Runnable::PlanThrough {
171 from: speccoder_core::actions::PlanFrom::Continue,
172 through: speccoder_core::actions::Through::Tasks,
173 });
174 let gate = crate::review::summary(path, feature).and_then(|r| {
175 if !r.questions.is_empty() {
176 Some(format!(
177 "answer the {} open question{} first",
178 r.questions.len(),
179 if r.questions.len() == 1 { "" } else { "s" }
180 ))
181 } else {
182 (!r.requirements.is_empty() && !r.totals.complete())
183 .then(|| "approve the spec's requirements first (Review)".to_owned())
184 }
185 });
186 a.blocked = gate;
187 out.push(a);
188 }
189 for name in &planning_next {
190 let why = match name.as_str() {
191 "plan" => "write the plan from the spec",
192 "tasks" => "break the plan into tasks",
193 _ => "the next planning step",
194 };
195 out.push(skill("next", name, why));
196 }
197 if !uncommitted.is_empty() {
198 let mut a = action(
199 "next",
200 format!("commit the planning files ({})", uncommitted.len()),
201 "speccoder runs from committed files",
202 format!(
203 "git add {0} && git commit -m \"spec: …\" -- {0}",
204 uncommitted.join(" ")
205 ),
206 );
207 a = a.with_run(Runnable::Commit {
208 paths: uncommitted.clone(),
209 });
210 out.push(a);
211 }
212 let ready = report.map_or(0, |r| r.totals.ready);
213 let run_blocked = (!uncommitted.is_empty())
214 .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
215 if ready > 0 && planning_next.is_empty() {
216 let mut a = action(
217 "next",
218 "run the feature",
219 format!("{ready} ready task(s), each judged on evidence"),
220 "speccoder run",
221 );
222 a = a.with_run(Runnable::Run {
223 task: None,
224 agent: 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 });
290 a.blocked = run_blocked.clone();
291 out.push(a);
292 }
293 if !matches!(
295 t.state.as_str(),
296 "verified" | "regression_only" | "attested"
297 ) {
298 for (verb, why, prompt, run) in [
299 (
300 "accept",
301 "tick it on your word (attested, not verified)",
302 "Why it is done",
303 Runnable::Accept { task: t.id.clone() },
304 ),
305 (
306 "skip",
307 "runs pass it by; what depends on it stays blocked",
308 "Why it is skipped",
309 Runnable::Skip { task: t.id.clone() },
310 ),
311 (
312 "nudge",
313 "a note its next attempts read",
314 "The note",
315 Runnable::Nudge { task: t.id.clone() },
316 ),
317 ] {
318 let flag = if verb == "nudge" {
319 "--note"
320 } else {
321 "--reason"
322 };
323 let mut a = action(
324 "task",
325 format!("{verb} {}", t.id),
326 why,
327 format!("speccoder task {verb} {} {flag} \"…\"", t.id),
328 );
329 a = a.with_run(run);
330 a.input = Some(prompt.to_owned());
331 out.push(a);
332 }
333 }
334 }
335 if ready > 0 && !planning_next.is_empty() {
336 let mut a = action(
338 "work",
339 "run the feature",
340 format!("{ready} ready task(s)"),
341 "speccoder run",
342 );
343 a = a.with_run(Runnable::Run {
344 task: None,
345 agent: None,
346 });
347 a.blocked = run_blocked;
348 out.push(a);
349 }
350
351 if feature_count > 1 {
353 let mut a = action(
354 "feature",
355 format!("switch the {unit}"),
356 format!("{feature_count} {unit}s"),
357 "speccoder feature <name>",
358 );
359 a = a.with_run(Runnable::Feature);
360 out.push(a);
361 }
362
363 let mut pr = action(
366 "review",
367 "open a draft pull request",
368 "push the branch and its evidence; open (or update) the draft",
369 "speccoder pr --yes",
370 );
371 pr = pr.with_run(Runnable::PullRequest);
372 pr.confirm = Some(
373 "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(),
374 );
375 out.push(pr);
376 out.push(action(
377 "review",
378 "verify the attestations",
379 "every tick against its commit and signature",
380 "speccoder attest verify",
381 ));
382 out.push(action(
383 "review",
384 "check the setup",
385 "git, the project, the config, the model key",
386 "speccoder doctor",
387 ));
388 out
389}
390
391#[must_use]
396pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
397 let report = || speccoder_core::ActionsReport {
398 actions: Vec::new(),
399 journey: journey(path, feature),
400 };
401 if crate::format::at(path).detect(path).is_none() {
402 let status = crate::status::report(path, feature);
403 return CommandResult::carried("actions", &status)
404 .with_data(CommandData::Actions(Box::new(report())));
405 }
406 let mut r = report();
407 r.actions = available(path, feature, selected);
408 let message = match speccoder_core::actions::primary(&r.actions) {
409 Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
410 None => format!("{} action(s)", r.actions.len()),
411 };
412 CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
413}
414
415#[must_use]
418pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
419 match crate::status::report(path, feature).data() {
420 Some(CommandData::Report(r)) => r.journey.clone(),
421 _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
422 }
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum Interface {
428 WebJobs,
430 CliFollow,
432 CliRemedy,
434 AcpRemedy,
436 Dashboard,
438}
439
440impl Interface {
441 pub const ALL: &'static [Self] = &[
443 Self::WebJobs,
444 Self::CliFollow,
445 Self::CliRemedy,
446 Self::AcpRemedy,
447 Self::Dashboard,
448 ];
449
450 #[must_use]
452 pub fn refusal(self) -> CommandResult {
453 match self {
454 Self::CliFollow => CommandResult::new(
455 "next",
456 CommandStatus::UsageError,
457 "not a step speccoder takes here",
458 ),
459 Self::Dashboard => CommandResult::new(
460 "tui",
461 CommandStatus::UsageError,
462 "not something the dashboard does",
463 ),
464 Self::WebJobs => CommandResult::new(
465 "job",
466 CommandStatus::UsageError,
467 "not something the web app runs",
468 ),
469 Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
470 "fix",
471 CommandStatus::UsageError,
472 "not a fix speccoder applies here",
473 ),
474 }
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum Offer {
481 Take,
483 Refuse,
485}
486
487#[must_use]
490pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
491 use ActionKind as K;
492 match interface {
493 Interface::WebJobs => match kind {
494 K::Skill
495 | K::RunTask
496 | K::RunFeature
497 | K::Converge
498 | K::Commit
499 | K::Approve
500 | K::Reject
501 | K::Accept
502 | K::Skip
503 | K::Nudge
504 | K::Done
505 | K::Hint
506 | K::Rule
507 | K::Review
508 | K::IssueComment
509 | K::QuickChange
510 | K::KeepSetAside
511 | K::DropSetAside
512 | K::FixSetup
513 | K::Feature
514 | K::PullRequest
515 | K::BugFix
516 | K::BugConfirm
517 | K::BugRun
518 | K::BugKeep
519 | K::AllowAlways
520 | K::WidenPolicy
521 | K::DecideBatch
522 | K::OracleTests
523 | K::ApproveAndRun
524 | K::Comment
525 | K::CommentsSend
526 | K::RevisionUndo
527 | K::CodeNudge
528 | K::AcceptanceAt
529 | K::PresetAdd
530 | K::PlanThrough
531 | K::NewProject
532 | K::UndoNewProject
533 | K::CloneRepository
534 | K::OpenSample
535 | K::SampleChecks => Offer::Take,
536 K::Command => Offer::Refuse,
537 },
538 Interface::CliFollow => match kind {
541 K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
543 Offer::Take
544 }
545 K::Approve
546 | K::Reject
547 | K::Accept
548 | K::Skip
549 | K::Nudge
550 | K::Done
551 | K::Hint
552 | K::Rule
553 | K::Review
554 | K::IssueComment
555 | K::QuickChange
556 | K::KeepSetAside
557 | K::DropSetAside
558 | K::FixSetup
559 | K::Feature
560 | K::PullRequest
561 | K::BugFix
562 | K::BugConfirm
563 | K::BugRun
564 | K::BugKeep
565 | K::AllowAlways
566 | K::WidenPolicy
567 | K::DecideBatch
568 | K::OracleTests
569 | K::ApproveAndRun
570 | K::Comment
571 | K::CommentsSend
572 | K::RevisionUndo
573 | K::CodeNudge
574 | K::AcceptanceAt
575 | K::PresetAdd
576 | K::NewProject
577 | K::UndoNewProject
578 | K::CloneRepository
579 | K::OpenSample
580 | K::SampleChecks
581 | K::Command => Offer::Refuse,
582 },
583 Interface::Dashboard => match kind {
586 K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
587 K::Skill
588 | K::RunTask
589 | K::RunFeature
590 | K::Converge
591 | K::Commit
592 | K::Accept
593 | K::Skip
594 | K::Nudge
595 | K::Done
596 | K::Hint
597 | K::Rule
598 | K::Review
599 | K::IssueComment
600 | K::QuickChange
601 | K::KeepSetAside
602 | K::DropSetAside
603 | K::FixSetup
604 | K::PullRequest
605 | K::BugFix
606 | K::BugConfirm
607 | K::BugRun
608 | K::BugKeep
609 | K::WidenPolicy
610 | K::OracleTests
611 | K::ApproveAndRun
612 | K::Comment
613 | K::CommentsSend
614 | K::RevisionUndo
615 | K::CodeNudge
616 | K::AcceptanceAt
617 | K::PresetAdd
618 | K::PlanThrough
619 | K::NewProject
620 | K::UndoNewProject
621 | K::CloneRepository
622 | K::OpenSample
623 | K::SampleChecks
624 | K::Command => Offer::Refuse,
625 },
626 Interface::CliRemedy | Interface::AcpRemedy => match kind {
627 K::FixSetup => Offer::Take,
628 K::Skill
629 | K::RunTask
630 | K::RunFeature
631 | K::Converge
632 | K::Commit
633 | K::Approve
634 | K::Reject
635 | K::Accept
636 | K::Skip
637 | K::Nudge
638 | K::Done
639 | K::Hint
640 | K::Rule
641 | K::Review
642 | K::IssueComment
643 | K::QuickChange
644 | K::KeepSetAside
645 | K::DropSetAside
646 | K::Feature
647 | K::PullRequest
648 | K::BugFix
649 | K::BugConfirm
650 | K::BugRun
651 | K::BugKeep
652 | K::AllowAlways
653 | K::WidenPolicy
654 | K::DecideBatch
655 | K::OracleTests
656 | K::ApproveAndRun
657 | K::Comment
658 | K::CommentsSend
659 | K::RevisionUndo
660 | K::CodeNudge
661 | K::AcceptanceAt
662 | K::PresetAdd
663 | K::PlanThrough
664 | K::NewProject
665 | K::UndoNewProject
666 | K::CloneRepository
667 | K::OpenSample
668 | K::SampleChecks
669 | K::Command => Offer::Refuse,
670 },
671 }
672}
673
674#[derive(Debug)]
678pub struct Dispatch<'a> {
679 pub run: &'a Runnable,
681 pub input: Option<&'a str>,
684 pub via: Via,
686 pub options: RunOptions,
688}
689
690impl<'a> Dispatch<'a> {
691 #[must_use]
693 pub fn new(run: &'a Runnable, via: Via) -> Self {
694 Self {
695 run,
696 input: None,
697 via,
698 options: RunOptions::default(),
699 }
700 }
701}
702
703#[must_use]
706pub fn perform_for(
707 interface: Interface,
708 root: &Path,
709 feature: Option<&str>,
710 d: Dispatch<'_>,
711) -> Option<CommandResult> {
712 (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
713}
714
715#[must_use]
720pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
721 perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
722}
723
724fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
726 RunOptions {
727 agent: agent.cloned().or_else(|| options.agent.clone()),
728 ..options.clone()
729 }
730}
731
732#[must_use]
734pub fn perform_in(
735 root: &Path,
736 feature: Option<&str>,
737 d: Dispatch<'_>,
738 state: Option<&Path>,
739) -> CommandResult {
740 use crate::approvals::{Choice, Verb};
741 let needs = |what: &str| {
742 CommandResult::new(
743 "job",
744 CommandStatus::UsageError,
745 &format!("{what} is needed"),
746 )
747 };
748 let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
749 let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
750 let options = &d.options;
751 let via = d.via;
752 let text = d.input.map(str::trim).filter(|t| !t.is_empty());
753 match d.run {
754 Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
755 Runnable::Run {
756 task: Some(t),
757 agent,
758 } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
759 Runnable::Run { task: None, agent } => {
760 run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
761 }
762 Runnable::Converge => run_feature(root, feature, options, true),
763 Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
764 Runnable::Approve { task, call, digest } => decide(Verb::Decide(
765 Choice::approve(task, text).shown(call, digest),
766 )),
767 Runnable::Reject { task, call, digest } => match text {
768 Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
769 None => needs("a reason"),
770 },
771 Runnable::Accept { task } => match (text, state) {
772 (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
773 (None, _) => needs("a reason"),
774 (_, None) => no_state(),
775 },
776 Runnable::AcceptanceAt {
778 feature: unit,
779 story,
780 task,
781 revoke,
782 } => match state {
783 Some(s) => {
784 crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
785 }
786 None => no_state(),
787 },
788 Runnable::PresetAdd { update } => match state {
790 Some(s) => crate::preset::add(root, *update, true, s),
791 None => no_state(),
792 },
793 Runnable::Skip { task } => match text {
794 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
795 None => needs("a reason"),
796 },
797 Runnable::Nudge { task } => match (text, state) {
798 (Some(note), Some(s)) => {
799 crate::controls::nudge_with(root, feature, task, Some(note), s)
800 }
801 (None, _) => needs("a note"),
802 (_, None) => no_state(),
803 },
804 Runnable::Done { task } => match state {
807 Some(s) => crate::controls::accept_in_session(
808 root,
809 feature,
810 task,
811 text.unwrap_or(DONE_REASON),
812 s,
813 options,
814 ),
815 None => no_state(),
816 },
817 Runnable::Hint { task } => match (text, state) {
819 (Some(note), Some(s)) => {
820 let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
821 if noted.status() == CommandStatus::Ok {
822 run_task(root, feature, task, options)
823 } else {
824 noted
825 }
826 }
827 (None, _) => needs("a hint"),
828 (_, None) => no_state(),
829 },
830 Runnable::Rule { task } => match (text, state) {
831 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
832 (None, _) => needs("a reason"),
833 (_, None) => no_state(),
834 },
835 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
836 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
837 ("edit" | "edit-approve", Some(id), Some(t)) => {
838 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
839 }
840 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
841 _ => crate::review::decide(root, feature, ids, verdict, text, via),
842 },
843 Runnable::IssueComment { issue, assign } => crate::github::comment(
844 &crate::github::Ctx::from_env(),
845 root,
846 feature,
847 &issue.to_string(),
848 *assign,
849 ),
850 Runnable::QuickChange {
851 request,
852 checks,
853 run,
854 } => quick_change(root, request, checks, *run, via, options),
855 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
856 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
857 Runnable::FixSetup => crate::code::fix_setup(root, feature),
858 Runnable::Feature => match text {
859 Some(name) => crate::current::switch(root, name),
860 None => needs("a feature"),
861 },
862 Runnable::BugFix {
863 symptom,
864 expected,
865 unchanged,
866 issue,
867 tests,
868 candidate,
869 slug,
870 run,
871 } => bug_fix(
872 root,
873 &crate::bug::Request::of(
874 symptom,
875 expected,
876 unchanged,
877 *issue,
878 tests,
879 candidate.as_deref(),
880 slug.as_deref(),
881 *run,
882 ),
883 via,
884 state,
885 options,
886 ),
887 Runnable::BugConfirm {
888 task,
889 digest,
890 reject,
891 } => match (*reject, text) {
892 (true, None) => needs("a reason"),
893 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
894 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
895 },
896 Runnable::BugRun { slug, task } => {
897 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
898 }
899 Runnable::BugKeep { task } => match text {
900 Some(why) => crate::bug::keep(root, task, why, state),
901 None => needs("a reason"),
902 },
903 Runnable::AllowAlways { task, call } => {
904 crate::approvals::allow_always(root, feature, task, call, state, via)
905 }
906 Runnable::WidenPolicy {
907 key,
908 prefix,
909 digest,
910 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
911 Runnable::DecideBatch {
912 run,
913 verdict,
914 calls,
915 digest,
916 resume_asking,
917 } => {
918 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
919 if !approve && text.is_none() {
920 return needs("a reason");
921 }
922 crate::approvals::decide_batch(
923 root,
924 feature,
925 crate::approvals::BatchChoice {
926 run,
927 approve,
928 calls,
929 digest,
930 resume_asking: *resume_asking,
931 reason: text,
932 },
933 state,
934 via,
935 )
936 }
937 Runnable::OracleTests {
938 req,
939 op,
940 path,
941 scenario,
942 reading,
943 } => crate::oracle::tests(
944 root,
945 feature,
946 &crate::oracle::Request {
947 req,
948 op: *op,
949 path: path.as_deref(),
950 scenario: scenario.as_deref(),
951 reading: *reading,
952 text: d.input.filter(|t| !t.trim().is_empty()),
953 },
954 via,
955 state,
956 options,
957 ),
958 Runnable::ApproveAndRun {
959 mode,
960 grants,
961 sheet,
962 } => crate::review::approve_and_run(
963 root,
964 feature,
965 *mode,
966 grants,
967 sheet.as_deref(),
968 via,
969 state,
970 None,
971 options,
972 ),
973 Runnable::Comment { op, anchor, id } => crate::comments::comment(
974 root,
975 feature,
976 *op,
977 anchor.as_ref(),
978 id.as_deref(),
979 text,
980 via,
981 state,
982 ),
983 Runnable::CommentsSend { ids } => {
984 crate::comments::send(root, feature, ids, via, state, options)
985 }
986 Runnable::RevisionUndo { turn } => {
987 crate::comments::undo(root, feature, turn.as_deref(), state)
988 }
989 Runnable::CodeNudge { task, comments } => {
990 crate::comments::code_nudge(root, feature, task, comments, state)
991 }
992 Runnable::PlanThrough {
995 from:
996 speccoder_core::actions::PlanFrom::Create {
997 description,
998 source,
999 },
1000 through,
1001 } => {
1002 let description = if description.trim().is_empty() {
1003 text.unwrap_or_default()
1004 } else {
1005 description.as_str()
1006 };
1007 plan_through(root, description, source, *through, options)
1008 }
1009 Runnable::PlanThrough {
1010 from: speccoder_core::actions::PlanFrom::Continue,
1011 through,
1012 } => crate::planning_chain::resume(root, feature, *through, options),
1013 Runnable::NewProject {
1015 target,
1016 choices,
1017 plan_digest,
1018 } => match state {
1019 Some(s) => crate::new_project::perform(
1020 Path::new(target),
1021 choices,
1022 plan_digest,
1023 via,
1024 s,
1025 options,
1026 ),
1027 None => no_state(),
1028 },
1029 Runnable::UndoNewProject { commit } => match state {
1030 Some(s) => crate::new_project::undo(root, commit, via, s, options),
1031 None => no_state(),
1032 },
1033 Runnable::CloneRepository { repo, target } => {
1035 crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1036 }
1037 Runnable::OpenSample { fresh } => match state {
1038 Some(s) => crate::sample::open(s, *fresh),
1039 None => no_state(),
1040 },
1041 Runnable::SampleChecks => match state {
1042 Some(s) => crate::sample::run_checks(s, root),
1043 None => no_state(),
1044 },
1045 Runnable::PullRequest => crate::handoff::pr(
1048 root,
1049 feature,
1050 &crate::handoff::PrOptions {
1051 remote: "origin",
1052 base: None,
1053 open: true,
1054 force: false,
1055 github: &crate::github::Ctx::from_env(),
1056 },
1057 ),
1058 }
1059}
1060
1061#[must_use]
1065pub fn allow_always_inline(
1066 root: &Path,
1067 feature: &str,
1068 always: &[crate::substrate::Always],
1069 state: &Path,
1070) -> Vec<String> {
1071 always
1072 .iter()
1073 .map(|a| {
1074 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1075 let run = Runnable::AllowAlways {
1076 task,
1077 call: a.call.clone(),
1078 };
1079 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1080 .message()
1081 .to_owned()
1082 })
1083 .collect()
1084}
1085
1086#[must_use]
1092pub fn answered_at_stop(
1093 root: &Path,
1094 feature: Option<&str>,
1095 task: &str,
1096 answer: &crate::approvals::StopAnswer,
1097 (via, run): (Via, &RunOptions),
1098 state: &Path,
1099) -> CommandResult {
1100 use crate::approvals::StopAnswer;
1101 let task = task.to_owned();
1102 let (what, input) = match answer {
1103 StopAnswer::Done => (Runnable::Done { task }, None),
1104 StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1105 StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1106 };
1107 perform_in(
1108 root,
1109 feature,
1110 Dispatch {
1111 input,
1112 options: RunOptions {
1113 in_place: run.in_place,
1114 session: run.session.clone(),
1115 ..RunOptions::default()
1116 },
1117 ..Dispatch::new(&what, via)
1118 },
1119 Some(state),
1120 )
1121}
1122
1123fn skill(
1128 root: &Path,
1129 feature: Option<&str>,
1130 name: &str,
1131 input: Option<&str>,
1132 carried: &str,
1133 options: &RunOptions,
1134) -> CommandResult {
1135 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1136}
1137
1138fn plan_through(
1139 root: &Path,
1140 description: &str,
1141 source: &speccoder_core::actions::FeatureSource,
1142 through: speccoder_core::actions::Through,
1143 options: &RunOptions,
1144) -> CommandResult {
1145 crate::planning_chain::start(root, description, source, through, options)
1146}
1147
1148fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1149 crate::run(root, feature, task, options)
1150}
1151
1152fn run_feature(
1153 root: &Path,
1154 feature: Option<&str>,
1155 options: &RunOptions,
1156 converge: bool,
1157) -> CommandResult {
1158 crate::run_feature(root, feature, options, converge)
1159}
1160
1161fn bug_fix(
1162 root: &Path,
1163 req: &crate::bug::Request,
1164 via: Via,
1165 state: Option<&Path>,
1166 options: &RunOptions,
1167) -> CommandResult {
1168 crate::bug::bug(root, req, via, state, options)
1169}
1170
1171fn bug_confirm(
1172 root: &Path,
1173 task: &str,
1174 digest: &str,
1175 reject: Option<&str>,
1176 via: Via,
1177 state: Option<&Path>,
1178 options: &RunOptions,
1179) -> CommandResult {
1180 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1181}
1182
1183fn bug_run(
1184 root: &Path,
1185 slug: Option<&str>,
1186 task: Option<&str>,
1187 state: Option<&Path>,
1188 options: &RunOptions,
1189) -> CommandResult {
1190 crate::bug::run(root, slug, task, state, options)
1191}
1192
1193fn quick_change(
1194 root: &Path,
1195 request: &str,
1196 checks: &[String],
1197 run: bool,
1198 via: Via,
1199 options: &RunOptions,
1200) -> CommandResult {
1201 crate::quick::change(root, request, checks, run, via, options)
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use speccoder_core::actions::primary;
1207
1208 use super::*;
1209
1210 #[test]
1211 fn every_interface_decides_every_kind() {
1212 let mut taken = 0;
1213 for i in Interface::ALL {
1214 for k in ActionKind::ALL {
1215 if offered(*i, *k) == Offer::Take {
1216 taken += 1;
1217 } else {
1218 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1219 }
1220 }
1221 }
1222 let runnable = Runnable::examples().len();
1226 assert_eq!(taken, runnable + 6 + 5 + 2);
1228 assert_eq!(
1229 offered(Interface::CliFollow, ActionKind::PullRequest),
1230 Offer::Refuse
1231 );
1232 assert_eq!(
1233 offered(Interface::AcpRemedy, ActionKind::FixSetup),
1234 Offer::Take
1235 );
1236 }
1237
1238 fn copied(line: &str) -> bool {
1241 let mut w = line.split_whitespace().skip(1);
1242 matches!(w.next(), Some("eval" | "replay" | "bench"))
1243 || line.contains("--remote <")
1244 || line.split_whitespace().any(|w| w == "-C")
1246 || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1248 && line.split_whitespace().count() == 3
1249 }
1250
1251 const PROSE: &[&str] = &[
1255 "speccoder did not ",
1256 "speccoder cannot ",
1257 "speccoder commits ",
1258 "speccoder could ",
1259 "speccoder failed ",
1260 "speccoder has ",
1261 "speccoder is ",
1262 "speccoder needs ",
1263 "speccoder reads ",
1264 "speccoder works ",
1265 "speccoder does not ",
1266 "speccoder implements ",
1267 "speccoder converges ",
1268 "speccoder runs ",
1269 "speccoder Documentation",
1270 "speccoder — ",
1271 "speccoder ✓",
1272 "speccoder ■",
1273 "speccoder ▸",
1274 "speccoder △",
1275 "speccoder ✗",
1276 "speccoder / ",
1277 "speccoder attempt: ",
1278 "speccoder manifest: ",
1279 "speccoder inspect: ",
1280 "speccoder preset ",
1281 "speccoder bundles",
1282 "speccoder {}: ",
1283 "speccoder {feature}: ",
1284 "speccoder 001-x: ",
1285 "speccoder {} · ",
1286 "speccoder {} --yes",
1288 ];
1289
1290 #[test]
1296 fn no_speccoder_command_is_copy_only() {
1297 let ctx = Context {
1298 feature: Some("001-x"),
1299 task: Some("T001"),
1300 };
1301 let mut seen = 0;
1302 let mut errors = Vec::new();
1303 for e in speccoder_core::codes::CATALOG {
1304 for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1305 if line.starts_with("speccoder ") && !copied(&line) {
1306 let how = runnable_of(&line, ctx).how;
1307 if how.copy_only() {
1308 errors.push(format!("{}: {line} → {how:?}", e.name));
1309 }
1310 seen += 1;
1311 }
1312 }
1313 }
1314 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1315 let mut files = Vec::new();
1316 for dir in [
1317 "src",
1318 "../speccoder-core/src",
1319 "../speccoder-web/src",
1320 "../speccoder/src",
1321 ] {
1322 let mut stack = vec![root.join(dir)];
1323 while let Some(d) = stack.pop() {
1324 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1325 let p = entry.path();
1326 if p.is_dir() {
1327 stack.push(p);
1328 } else if p.extension().is_some_and(|x| x == "rs") {
1329 files.push(p);
1330 }
1331 }
1332 }
1333 }
1334 assert!(files.len() > 100, "{} files", files.len());
1335 for f in files {
1336 let src = std::fs::read_to_string(&f).unwrap_or_default();
1337 let src = src
1339 .split("#[cfg(test)]\nmod tests")
1340 .next()
1341 .unwrap_or_default();
1342 for (at, _) in src.match_indices("\"speccoder ") {
1343 if src[..at].ends_with('\\') {
1345 continue;
1346 }
1347 let mut line = String::new();
1348 let mut chars = src[at + 1..].chars();
1349 while let Some(c) = chars.next() {
1350 match c {
1351 '\\' => line.extend(chars.next()),
1352 '"' => break,
1353 c => line.push(c),
1354 }
1355 }
1356 let mut filled = String::new();
1358 let mut rest = line.as_str();
1359 while let Some(open) = rest.find('{') {
1360 filled.push_str(&rest[..open]);
1361 let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1362 let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1364 filled.push_str(match &rest[open..close] {
1365 _ if suffix => "",
1366 "{task}" | "{t}" => "T001",
1367 "{feature}" | "{f}" => "001-x",
1368 "{command}" | "{verb}" => "run",
1369 _ if filled.ends_with("--through ") => "tasks",
1370 _ => "x",
1371 });
1372 rest = &rest[close.min(rest.len())..];
1373 }
1374 filled.push_str(rest);
1375 if line == "speccoder "
1377 || copied(&filled)
1378 || PROSE.iter().any(|p| line.starts_with(p))
1379 {
1380 continue;
1381 }
1382 let how = runnable_of(&filled, ctx).how;
1383 if how.copy_only() {
1384 errors.push(format!("{}: {filled} → {how:?}", f.display()));
1385 }
1386 seen += 1;
1387 }
1388 }
1389 assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1390 assert!(seen > 100, "{seen} lines seen");
1391 }
1392
1393 #[test]
1394 fn blank_input_is_no_input() {
1395 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1396 let run = Runnable::Reject {
1397 task: "T001".to_owned(),
1398 call: "attempt:T001".to_owned(),
1399 digest: String::new(),
1400 };
1401 let r = perform_in(
1402 dir.path(),
1403 None,
1404 Dispatch {
1405 run: &run,
1406 input: Some(" "),
1407 via: Via::Web,
1408 options: RunOptions::default(),
1409 },
1410 None,
1411 );
1412 assert_eq!(
1413 (r.command(), r.status(), r.message()),
1414 ("job", CommandStatus::UsageError, "a reason is needed")
1415 );
1416 }
1417
1418 #[test]
1419 fn a_project_outside_git_still_gets_a_list() {
1420 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1421 let list = available(dir.path(), None, None);
1422 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1423 assert!(primary(&list).is_none());
1424 }
1425}