1use std::path::Path;
35
36use speccoder_core::actions::{Action, ActionKind, Runnable, Via};
37use speccoder_core::{ApprovalsReport, CommandData, CommandResult, CommandStatus, StatusReport};
38
39use crate::RunOptions;
40
41pub use speccoder_core::step::{Context, How, Step, View, step_of as runnable_of};
46
47pub(crate) fn action(
48 group: &str,
49 label: impl Into<String>,
50 why: impl Into<String>,
51 command: impl Into<String>,
52) -> Action {
53 Action::new(group, label, why, command)
54}
55
56pub const DONE_REASON: &str = "I did it";
58
59#[must_use]
62pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
63 available_with(path, feature, selected, None)
64}
65
66#[must_use]
69pub fn available_with(
70 path: &Path,
71 feature: Option<&str>,
72 selected: Option<&str>,
73 listed: Option<&ApprovalsReport>,
74) -> Vec<Action> {
75 let report = crate::status::report(path, feature);
76 let report: Option<&StatusReport> = match report.data() {
77 Some(CommandData::Report(r)) => Some(r),
78 _ => None,
79 };
80 let approvals = listed.cloned().or_else(|| {
81 crate::worktree::state_dir().and_then(|state| {
82 match crate::approvals::list_with(path, feature, &state).data() {
83 Some(CommandData::Approvals(a)) => Some((**a).clone()),
84 _ => None,
85 }
86 })
87 });
88 let uncommitted = crate::hosting::uncommitted(path, feature);
89 let skills = match crate::hosting::list(path).data() {
90 Some(CommandData::Skills(s)) => s.skills.clone(),
91 _ => Vec::new(),
92 };
93 let format = crate::format::at(path);
94 let planning = format.planning();
95 let unit = format.messages().unit;
96 let features = crate::current::list(path, None);
97 let feature_count = match features.data() {
98 Some(CommandData::Features(f)) => f.features.len(),
99 _ => 0,
100 };
101 let short = |name: &str| {
102 name.strip_prefix(planning.prefix)
103 .unwrap_or(name)
104 .to_owned()
105 };
106 let skill = |group: &str, name: &str, why: &str| {
107 let creates = planning.creates.contains(&name);
108 let mut a = action(
109 group,
110 format!("spec {name}"),
111 why,
112 if creates {
113 format!("speccoder spec {name} \"…\"")
114 } else {
115 format!("speccoder spec {name}")
116 },
117 );
118 a = a
119 .with_run(Runnable::Skill {
120 name: name.to_owned(),
121 arguments: String::new(),
122 })
123 .with_role(planning.role_of(name));
124 if creates {
125 a.input = Some(format!("Describe the {unit}"));
126 }
127 a
128 };
129 let mut out = Vec::new();
130
131 for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
133 let mut a = action(
134 "next",
135 format!("approve {}'s waiting call", p.task),
136 format!("{} — {}", p.covers, p.reason),
137 format!("speccoder task approve {}", p.task),
138 );
139 a = a.with_run(Runnable::Approve {
140 task: p.task.clone(),
141 call: p.id(),
142 digest: p.digest.clone(),
143 });
144 out.push(a);
145 let mut r = action(
146 "next",
147 format!("reject {}'s waiting call", p.task),
148 "the model is told your reason",
149 format!("speccoder task reject {} --reason \"…\"", p.task),
150 );
151 r = r.with_run(Runnable::Reject {
152 task: p.task.clone(),
153 call: p.id(),
154 digest: p.digest.clone(),
155 });
156 r.input = Some("Why (the model is told)".to_owned());
157 out.push(r);
158 }
159 let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
160 if let Some(first) = planning_next.first() {
163 let mut a = action(
164 "next",
165 "plan through to tasks",
166 "the format's own skills in order; it stops for open questions and your review",
167 format!("speccoder spec {first} --through tasks"),
168 );
169 a = a.with_run(Runnable::PlanThrough {
170 from: speccoder_core::actions::PlanFrom::Continue,
171 through: speccoder_core::actions::Through::Tasks,
172 });
173 let gate = crate::review::summary(path, feature).and_then(|r| {
174 if !r.questions.is_empty() {
175 Some(format!(
176 "answer the {} open question{} first",
177 r.questions.len(),
178 if r.questions.len() == 1 { "" } else { "s" }
179 ))
180 } else {
181 (!r.requirements.is_empty() && !r.totals.complete())
182 .then(|| "approve the spec's requirements first (Review)".to_owned())
183 }
184 });
185 a.blocked = gate;
186 out.push(a);
187 }
188 for name in &planning_next {
189 let why = match name.as_str() {
190 "plan" => "write the plan from the spec",
191 "tasks" => "break the plan into tasks",
192 _ => "the next planning step",
193 };
194 out.push(skill("next", name, why));
195 }
196 if !uncommitted.is_empty() {
197 let mut a = action(
198 "next",
199 format!("commit the planning files ({})", uncommitted.len()),
200 "speccoder runs from committed files",
201 format!(
202 "git add {0} && git commit -m \"spec: …\" -- {0}",
203 uncommitted.join(" ")
204 ),
205 );
206 a = a.with_run(Runnable::Commit {
207 paths: uncommitted.clone(),
208 });
209 out.push(a);
210 }
211 let ready = report.map_or(0, |r| r.totals.ready);
212 let run_blocked = (!uncommitted.is_empty())
213 .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
214 if ready > 0 && planning_next.is_empty() {
215 let mut a = action(
216 "next",
217 "run the feature",
218 format!("{ready} ready task(s), each judged on evidence"),
219 "speccoder run",
220 );
221 a = a.with_run(Runnable::Run {
222 task: None,
223 agent: None,
224 });
225 a.blocked = run_blocked.clone();
226 out.push(a);
227 }
228 let stale: Vec<&str> = report
231 .map(|r| {
232 r.tasks
233 .iter()
234 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
235 .map(|t| t.id.as_str())
236 .collect()
237 })
238 .unwrap_or_default();
239 if !stale.is_empty() && planning_next.is_empty() {
240 let mut a = action(
241 "next",
242 "re-check what the spec changed",
243 format!(
244 "{} was proven against an earlier wording; convergence assesses the code against the spec as it is now and adds tasks for what no longer holds",
245 stale.join(", ")
246 ),
247 "speccoder run --converge",
248 );
249 a = a.with_run(Runnable::Converge);
250 a.blocked = run_blocked.clone();
251 out.push(a);
252 }
253 if report.is_none()
254 && feature_count == 0
255 && format.detect(path).is_some()
256 && let Some(create) = planning.creates.first()
257 {
258 out.push(skill(
259 "next",
260 create,
261 &format!("write the first {unit}'s spec"),
262 ));
263 }
264
265 for s in skills.iter().filter(|s| s.refused.is_none()) {
267 let name = short(&s.name);
268 if planning_next.contains(&name) {
269 continue;
270 }
271 out.push(skill("plan", &name, &s.description));
272 }
273
274 if let Some(t) =
276 selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
277 {
278 if t.state == "ready" {
279 let mut a = action(
280 "work",
281 format!("run {}", t.id),
282 t.title.clone(),
283 format!("speccoder run --task {}", t.id),
284 );
285 a = a.with_run(Runnable::Run {
286 task: Some(t.id.clone()),
287 agent: None,
288 });
289 a.blocked = run_blocked.clone();
290 out.push(a);
291 }
292 if let (Some(r), Some(state)) = (report, crate::worktree::state_dir()) {
294 let plane = crate::worktree::project_state_dir(&state, path);
295 if let Some(o) =
296 crate::ladder::offer_for(path, &plane, &format!("{}/{}", r.feature, t.id))
297 {
298 let at = format!("{}/{}", o.unit, o.task);
299 out.push(
300 action(
301 "review",
302 format!("start a spec from {}", t.id),
303 o.signals
304 .iter()
305 .map(|s| s.text.clone())
306 .collect::<Vec<_>>()
307 .join(" · "),
308 format!("speccoder spec promote {at}"),
309 )
310 .with_run(crate::ladder::promotion(
311 &o,
312 crate::ladder::suggested_by(&plane, &o),
313 )),
314 );
315 out.push(
316 action(
317 "review",
318 format!(
319 "not now: keep {} a {}",
320 t.id,
321 if o.bug { "bug fix" } else { "quick change" }
322 ),
323 "offered again only when another signal holds",
324 format!("speccoder spec promote {at} --decline"),
325 )
326 .with_run(Runnable::DeclinePromotion { task: at }),
327 );
328 }
329 }
330 if !matches!(
332 t.state.as_str(),
333 "verified" | "regression_only" | "attested"
334 ) {
335 let promoted = report.zip(crate::worktree::state_dir()).and_then(|(r, s)| {
338 crate::ladder::promoted_to(
339 &crate::worktree::project_state_dir(&s, path),
340 &format!("{}/{}", r.feature, t.id),
341 )
342 });
343 let skip_why = promoted.as_ref().map_or_else(
344 || "runs pass it by; what depends on it stays blocked".to_owned(),
345 |to| format!("it goes on as {to}; runs here pass it by"),
346 );
347 let mut verbs = [
348 (
349 "accept",
350 "tick it on your word (attested, not verified)".to_owned(),
351 "Why it is done",
352 Runnable::Accept { task: t.id.clone() },
353 ),
354 (
355 "skip",
356 skip_why,
357 "Why it is skipped",
358 Runnable::Skip { task: t.id.clone() },
359 ),
360 (
361 "nudge",
362 "a note its next attempts read".to_owned(),
363 "The note",
364 Runnable::Nudge { task: t.id.clone() },
365 ),
366 ];
367 if promoted.is_some() {
368 verbs.swap(0, 1);
369 }
370 for (verb, why, prompt, run) in verbs {
371 let flag = if verb == "nudge" {
372 "--note"
373 } else {
374 "--reason"
375 };
376 let mut a = action(
377 "task",
378 format!("{verb} {}", t.id),
379 why,
380 format!("speccoder task {verb} {} {flag} \"…\"", t.id),
381 );
382 a = a.with_run(run);
383 a.input = Some(prompt.to_owned());
384 out.push(a);
385 }
386 }
387 }
388 if ready > 0 && !planning_next.is_empty() {
389 let mut a = action(
391 "work",
392 "run the feature",
393 format!("{ready} ready task(s)"),
394 "speccoder run",
395 );
396 a = a.with_run(Runnable::Run {
397 task: None,
398 agent: None,
399 });
400 a.blocked = run_blocked;
401 out.push(a);
402 }
403
404 if feature_count > 1 {
406 let mut a = action(
407 "feature",
408 format!("switch the {unit}"),
409 format!("{feature_count} {unit}s"),
410 "speccoder feature <name>",
411 );
412 a = a.with_run(Runnable::Feature);
413 out.push(a);
414 }
415
416 let mut pr = action(
419 "review",
420 "open a draft pull request",
421 "push the branch and its evidence; open (or update) the draft",
422 "speccoder pr --yes",
423 );
424 pr = pr.with_run(Runnable::PullRequest);
425 pr.confirm = Some(
426 "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).".to_owned(),
427 );
428 out.push(pr);
429 out.push(action(
430 "review",
431 "verify the attestations",
432 "every tick against its commit and signature",
433 "speccoder attest verify",
434 ));
435 out.push(action(
436 "review",
437 "check the setup",
438 "git, the project, the config, the model key",
439 "speccoder doctor",
440 ));
441 out
442}
443
444#[must_use]
449pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
450 let report = || speccoder_core::ActionsReport {
451 actions: Vec::new(),
452 journey: journey(path, feature),
453 };
454 if crate::format::at(path).detect(path).is_none() {
455 let status = crate::status::report(path, feature);
456 return CommandResult::carried("actions", &status)
457 .with_data(CommandData::Actions(Box::new(report())));
458 }
459 let mut r = report();
460 r.actions = available(path, feature, selected);
461 let message = match speccoder_core::actions::primary(&r.actions) {
462 Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
463 None => format!("{} action(s)", r.actions.len()),
464 };
465 CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
466}
467
468#[must_use]
471pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
472 match crate::status::report(path, feature).data() {
473 Some(CommandData::Report(r)) => r.journey.clone(),
474 _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum Interface {
481 WebJobs,
483 CliFollow,
485 CliRemedy,
487 AcpRemedy,
489 Dashboard,
491 AskProposal,
493}
494
495impl Interface {
496 pub const ALL: &'static [Self] = &[
498 Self::WebJobs,
499 Self::CliFollow,
500 Self::CliRemedy,
501 Self::AcpRemedy,
502 Self::Dashboard,
503 Self::AskProposal,
504 ];
505
506 #[must_use]
508 pub fn refusal(self) -> CommandResult {
509 match self {
510 Self::CliFollow => CommandResult::new(
511 "next",
512 CommandStatus::UsageError,
513 "not a step speccoder takes here",
514 ),
515 Self::Dashboard => CommandResult::new(
516 "tui",
517 CommandStatus::UsageError,
518 "not something the dashboard does",
519 ),
520 Self::WebJobs => CommandResult::new(
521 "job",
522 CommandStatus::UsageError,
523 "not something the web app runs",
524 ),
525 Self::AskProposal => CommandResult::new(
526 "ask",
527 CommandStatus::UsageError,
528 "not something an Ask proposal records",
529 ),
530 Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
531 "fix",
532 CommandStatus::UsageError,
533 "not a fix speccoder applies here",
534 ),
535 }
536 }
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541pub enum Offer {
542 Take,
544 Refuse,
546}
547
548#[must_use]
551pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
552 use ActionKind as K;
553 match interface {
554 Interface::WebJobs => match kind {
555 K::Skill
556 | K::RunTask
557 | K::RunFeature
558 | K::Converge
559 | K::Commit
560 | K::Approve
561 | K::Reject
562 | K::Accept
563 | K::Skip
564 | K::Nudge
565 | K::Done
566 | K::Hint
567 | K::Rule
568 | K::Review
569 | K::IssueComment
570 | K::QuickChange
571 | K::KeepSetAside
572 | K::DropSetAside
573 | K::FixSetup
574 | K::Feature
575 | K::PullRequest
576 | K::BugFix
577 | K::BugConfirm
578 | K::BugRun
579 | K::BugKeep
580 | K::AllowAlways
581 | K::WidenPolicy
582 | K::DecideBatch
583 | K::OracleTests
584 | K::ApproveAndRun
585 | K::Comment
586 | K::CommentsSend
587 | K::RevisionUndo
588 | K::CodeNudge
589 | K::AcceptanceAt
590 | K::PresetAdd
591 | K::PlanThrough
592 | K::NewProject
593 | K::UndoNewProject
594 | K::CloneRepository
595 | K::OpenSample
596 | K::SampleChecks
597 | K::DeclinePromotion
598 | K::Explore
599 | K::ExploreChoose => Offer::Take,
600 K::Command => Offer::Refuse,
601 },
602 Interface::CliFollow => match kind {
605 K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
607 Offer::Take
608 }
609 K::Approve
610 | K::Reject
611 | K::Accept
612 | K::Skip
613 | K::Nudge
614 | K::Done
615 | K::Hint
616 | K::Rule
617 | K::Review
618 | K::IssueComment
619 | K::QuickChange
620 | K::KeepSetAside
621 | K::DropSetAside
622 | K::FixSetup
623 | K::Feature
624 | K::PullRequest
625 | K::BugFix
626 | K::BugConfirm
627 | K::BugRun
628 | K::BugKeep
629 | K::AllowAlways
630 | K::WidenPolicy
631 | K::DecideBatch
632 | K::OracleTests
633 | K::ApproveAndRun
634 | K::Comment
635 | K::CommentsSend
636 | K::RevisionUndo
637 | K::CodeNudge
638 | K::AcceptanceAt
639 | K::PresetAdd
640 | K::NewProject
641 | K::UndoNewProject
642 | K::CloneRepository
643 | K::OpenSample
644 | K::SampleChecks
645 | K::DeclinePromotion
646 | K::Explore
647 | K::ExploreChoose
648 | K::Command => Offer::Refuse,
649 },
650 Interface::AskProposal => match kind {
653 K::QuickChange | K::BugFix | K::Nudge | K::Review | K::PlanThrough => Offer::Take,
654 K::Skill
655 | K::RunTask
656 | K::RunFeature
657 | K::Converge
658 | K::Commit
659 | K::Approve
660 | K::Reject
661 | K::Accept
662 | K::Skip
663 | K::Done
664 | K::Hint
665 | K::Rule
666 | K::IssueComment
667 | K::KeepSetAside
668 | K::DropSetAside
669 | K::FixSetup
670 | K::Feature
671 | K::PullRequest
672 | K::BugConfirm
673 | K::BugRun
674 | K::BugKeep
675 | K::AllowAlways
676 | K::WidenPolicy
677 | K::DecideBatch
678 | K::OracleTests
679 | K::ApproveAndRun
680 | K::Comment
681 | K::CommentsSend
682 | K::RevisionUndo
683 | K::CodeNudge
684 | K::AcceptanceAt
685 | K::PresetAdd
686 | K::NewProject
687 | K::UndoNewProject
688 | K::CloneRepository
689 | K::OpenSample
690 | K::SampleChecks
691 | K::DeclinePromotion
692 | K::Explore
693 | K::ExploreChoose
694 | K::Command => Offer::Refuse,
695 },
696 Interface::Dashboard => match kind {
699 K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
700 K::Skill
701 | K::RunTask
702 | K::RunFeature
703 | K::Converge
704 | K::Commit
705 | K::Accept
706 | K::Skip
707 | K::Nudge
708 | K::Done
709 | K::Hint
710 | K::Rule
711 | K::Review
712 | K::IssueComment
713 | K::QuickChange
714 | K::KeepSetAside
715 | K::DropSetAside
716 | K::FixSetup
717 | K::PullRequest
718 | K::BugFix
719 | K::BugConfirm
720 | K::BugRun
721 | K::BugKeep
722 | K::WidenPolicy
723 | K::OracleTests
724 | K::ApproveAndRun
725 | K::Comment
726 | K::CommentsSend
727 | K::RevisionUndo
728 | K::CodeNudge
729 | K::AcceptanceAt
730 | K::PresetAdd
731 | K::PlanThrough
732 | K::NewProject
733 | K::UndoNewProject
734 | K::CloneRepository
735 | K::OpenSample
736 | K::SampleChecks
737 | K::DeclinePromotion
738 | K::Explore
739 | K::ExploreChoose
740 | K::Command => Offer::Refuse,
741 },
742 Interface::CliRemedy | Interface::AcpRemedy => match kind {
743 K::FixSetup => Offer::Take,
744 K::Skill
745 | K::RunTask
746 | K::RunFeature
747 | K::Converge
748 | K::Commit
749 | K::Approve
750 | K::Reject
751 | K::Accept
752 | K::Skip
753 | K::Nudge
754 | K::Done
755 | K::Hint
756 | K::Rule
757 | K::Review
758 | K::IssueComment
759 | K::QuickChange
760 | K::KeepSetAside
761 | K::DropSetAside
762 | K::Feature
763 | K::PullRequest
764 | K::BugFix
765 | K::BugConfirm
766 | K::BugRun
767 | K::BugKeep
768 | K::AllowAlways
769 | K::WidenPolicy
770 | K::DecideBatch
771 | K::OracleTests
772 | K::ApproveAndRun
773 | K::Comment
774 | K::CommentsSend
775 | K::RevisionUndo
776 | K::CodeNudge
777 | K::AcceptanceAt
778 | K::PresetAdd
779 | K::PlanThrough
780 | K::NewProject
781 | K::UndoNewProject
782 | K::CloneRepository
783 | K::OpenSample
784 | K::SampleChecks
785 | K::DeclinePromotion
786 | K::Explore
787 | K::ExploreChoose
788 | K::Command => Offer::Refuse,
789 },
790 }
791}
792
793#[derive(Debug)]
797pub struct Dispatch<'a> {
798 pub run: &'a Runnable,
800 pub input: Option<&'a str>,
803 pub via: Via,
805 pub options: RunOptions,
807}
808
809impl<'a> Dispatch<'a> {
810 #[must_use]
812 pub fn new(run: &'a Runnable, via: Via) -> Self {
813 Self {
814 run,
815 input: None,
816 via,
817 options: RunOptions::default(),
818 }
819 }
820}
821
822#[must_use]
825pub fn perform_for(
826 interface: Interface,
827 root: &Path,
828 feature: Option<&str>,
829 d: Dispatch<'_>,
830) -> Option<CommandResult> {
831 (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
832}
833
834#[must_use]
839pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
840 perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
841}
842
843fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
845 RunOptions {
846 agent: agent.cloned().or_else(|| options.agent.clone()),
847 ..options.clone()
848 }
849}
850
851#[must_use]
853pub fn perform_in(
854 root: &Path,
855 feature: Option<&str>,
856 d: Dispatch<'_>,
857 state: Option<&Path>,
858) -> CommandResult {
859 use crate::approvals::{Choice, Verb};
860 let needs = |what: &str| {
861 CommandResult::new(
862 "job",
863 CommandStatus::UsageError,
864 &format!("{what} is needed"),
865 )
866 };
867 let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
868 let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
869 let options = &d.options;
870 let via = d.via;
871 let text = d.input.map(str::trim).filter(|t| !t.is_empty());
872 match d.run {
873 Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
874 Runnable::Run {
875 task: Some(t),
876 agent,
877 } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
878 Runnable::Run { task: None, agent } => {
879 run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
880 }
881 Runnable::Converge => run_feature(root, feature, options, true),
882 Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
883 Runnable::Approve { task, call, digest } => decide(Verb::Decide(
884 Choice::approve(task, text).shown(call, digest),
885 )),
886 Runnable::Reject { task, call, digest } => match text {
887 Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
888 None => needs("a reason"),
889 },
890 Runnable::Accept { task } => match (text, state) {
891 (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
892 (None, _) => needs("a reason"),
893 (_, None) => no_state(),
894 },
895 Runnable::AcceptanceAt {
897 feature: unit,
898 story,
899 task,
900 revoke,
901 } => match state {
902 Some(s) => {
903 crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
904 }
905 None => no_state(),
906 },
907 Runnable::PresetAdd { update } => match state {
909 Some(s) => crate::preset::add(root, *update, true, s),
910 None => no_state(),
911 },
912 Runnable::Skip { task } => match text {
913 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
914 None => needs("a reason"),
915 },
916 Runnable::Nudge { task } => match (text, state) {
917 (Some(note), Some(s)) => {
918 crate::controls::nudge_with(root, feature, task, Some(note), s)
919 }
920 (None, _) => needs("a note"),
921 (_, None) => no_state(),
922 },
923 Runnable::Done { task } => match state {
926 Some(s) => crate::controls::accept_in_session(
927 root,
928 feature,
929 task,
930 text.unwrap_or(DONE_REASON),
931 s,
932 options,
933 ),
934 None => no_state(),
935 },
936 Runnable::Hint { task } => match (text, state) {
938 (Some(note), Some(s)) => {
939 let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
940 if noted.status() == CommandStatus::Ok {
941 run_task(root, feature, task, options)
942 } else {
943 noted
944 }
945 }
946 (None, _) => needs("a hint"),
947 (_, None) => no_state(),
948 },
949 Runnable::Rule { task } => match (text, state) {
950 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
951 (None, _) => needs("a reason"),
952 (_, None) => no_state(),
953 },
954 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
955 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
956 ("edit" | "edit-approve", Some(id), Some(t)) => {
957 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
958 }
959 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
960 _ => crate::review::decide(root, feature, ids, verdict, text, via),
961 },
962 Runnable::IssueComment { issue, assign } => crate::github::comment(
963 &crate::github::Ctx::from_env(),
964 root,
965 feature,
966 &issue.to_string(),
967 *assign,
968 ),
969 Runnable::QuickChange {
970 request,
971 checks,
972 run,
973 asked,
974 } => quick_change(
975 root,
976 request,
977 checks,
978 *run,
979 via,
980 options,
981 asked.as_deref().zip(state),
982 ),
983 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
984 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
985 Runnable::FixSetup => crate::code::fix_setup(root, feature),
986 Runnable::Feature => match text {
987 Some(name) => crate::current::switch(root, name),
988 None => needs("a feature"),
989 },
990 Runnable::BugFix {
991 symptom,
992 expected,
993 unchanged,
994 issue,
995 tests,
996 candidate,
997 slug,
998 run,
999 asked,
1000 } => bug_fix(
1001 root,
1002 &crate::bug::Request {
1003 asked: asked.clone(),
1004 ..crate::bug::Request::of(
1005 symptom,
1006 expected,
1007 unchanged,
1008 *issue,
1009 tests,
1010 candidate.as_deref(),
1011 slug.as_deref(),
1012 *run,
1013 )
1014 },
1015 via,
1016 state,
1017 options,
1018 ),
1019 Runnable::BugConfirm {
1020 task,
1021 digest,
1022 reject,
1023 } => match (*reject, text) {
1024 (true, None) => needs("a reason"),
1025 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1026 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1027 },
1028 Runnable::BugRun { slug, task } => {
1029 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1030 }
1031 Runnable::BugKeep { task } => match text {
1032 Some(why) => crate::bug::keep(root, task, why, state),
1033 None => needs("a reason"),
1034 },
1035 Runnable::AllowAlways { task, call } => {
1036 crate::approvals::allow_always(root, feature, task, call, state, via)
1037 }
1038 Runnable::WidenPolicy {
1039 key,
1040 prefix,
1041 digest,
1042 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1043 Runnable::DecideBatch {
1044 run,
1045 verdict,
1046 calls,
1047 digest,
1048 resume_asking,
1049 } => {
1050 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1051 if !approve && text.is_none() {
1052 return needs("a reason");
1053 }
1054 crate::approvals::decide_batch(
1055 root,
1056 feature,
1057 crate::approvals::BatchChoice {
1058 run,
1059 approve,
1060 calls,
1061 digest,
1062 resume_asking: *resume_asking,
1063 reason: text,
1064 },
1065 state,
1066 via,
1067 )
1068 }
1069 Runnable::OracleTests {
1070 req,
1071 op,
1072 path,
1073 scenario,
1074 reading,
1075 } => crate::oracle::tests(
1076 root,
1077 feature,
1078 &crate::oracle::Request {
1079 req,
1080 op: *op,
1081 path: path.as_deref(),
1082 scenario: scenario.as_deref(),
1083 reading: *reading,
1084 text: d.input.filter(|t| !t.trim().is_empty()),
1085 },
1086 via,
1087 state,
1088 options,
1089 ),
1090 Runnable::ApproveAndRun {
1091 mode,
1092 grants,
1093 sheet,
1094 } => crate::review::approve_and_run(
1095 root,
1096 feature,
1097 *mode,
1098 grants,
1099 sheet.as_deref(),
1100 via,
1101 state,
1102 None,
1103 options,
1104 ),
1105 Runnable::Comment { op, anchor, id } => crate::comments::comment(
1106 root,
1107 feature,
1108 *op,
1109 anchor.as_ref(),
1110 id.as_deref(),
1111 text,
1112 via,
1113 state,
1114 ),
1115 Runnable::CommentsSend { ids } => {
1116 crate::comments::send(root, feature, ids, via, state, options)
1117 }
1118 Runnable::Explore {
1119 idea,
1120 skill,
1121 resume,
1122 } => crate::explore::explore(
1123 root,
1124 text.unwrap_or(idea),
1125 skill.as_deref(),
1126 resume.as_deref(),
1127 options,
1128 ),
1129 Runnable::ExploreChoose {
1130 session,
1131 approach,
1132 constraints,
1133 questions,
1134 flag,
1135 } => crate::explore::choose(
1136 root,
1137 state,
1138 session,
1139 *approach,
1140 constraints,
1141 questions,
1142 via,
1143 if *flag { "flag" } else { "prompt" },
1144 ),
1145 Runnable::RevisionUndo { turn } => {
1146 crate::comments::undo(root, feature, turn.as_deref(), state)
1147 }
1148 Runnable::CodeNudge { task, comments } => {
1149 crate::comments::code_nudge(root, feature, task, comments, state)
1150 }
1151 Runnable::PlanThrough {
1154 from:
1155 speccoder_core::actions::PlanFrom::Create {
1156 description,
1157 source,
1158 },
1159 through,
1160 } => {
1161 let description = if description.trim().is_empty() {
1162 text.unwrap_or_default()
1163 } else {
1164 description.as_str()
1165 };
1166 match source {
1167 speccoder_core::actions::FeatureSource::Promotion {
1170 task,
1171 conversations,
1172 } => {
1173 let Some(s) = state else { return no_state() };
1174 let input = crate::ladder::promotion_input(root, s, description, conversations);
1175 let made = plan_through(root, &input, source, *through, options);
1176 crate::ladder::promoted(
1177 root,
1178 s,
1179 &made,
1180 task,
1181 conversations,
1182 via,
1183 options.progress.as_ref(),
1184 );
1185 made
1186 }
1187 speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1190 let Some(s) = state else { return no_state() };
1191 if let Err(refused) =
1192 crate::explore::verify_start(root, s, session, digest, description)
1193 {
1194 return *refused;
1195 }
1196 let made = plan_through(root, description, source, *through, options);
1197 crate::explore::explored(root, s, &made, session, digest, via);
1198 made
1199 }
1200 _ => plan_through(root, description, source, *through, options),
1201 }
1202 }
1203 Runnable::PlanThrough {
1204 from: speccoder_core::actions::PlanFrom::Continue,
1205 through,
1206 } => crate::planning_chain::resume(root, feature, *through, options),
1207 Runnable::NewProject {
1209 target,
1210 choices,
1211 plan_digest,
1212 } => match state {
1213 Some(s) => crate::new_project::perform(
1214 Path::new(target),
1215 choices,
1216 plan_digest,
1217 via,
1218 s,
1219 options,
1220 ),
1221 None => no_state(),
1222 },
1223 Runnable::UndoNewProject { commit } => match state {
1224 Some(s) => crate::new_project::undo(root, commit, via, s, options),
1225 None => no_state(),
1226 },
1227 Runnable::CloneRepository { repo, target } => {
1229 crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1230 }
1231 Runnable::OpenSample { fresh } => match state {
1232 Some(s) => crate::sample::open(s, *fresh),
1233 None => no_state(),
1234 },
1235 Runnable::SampleChecks => match state {
1236 Some(s) => crate::sample::run_checks(s, root),
1237 None => no_state(),
1238 },
1239 Runnable::DeclinePromotion { task } => match state {
1240 Some(s) => crate::ladder::decline_task(root, s, task),
1241 None => no_state(),
1242 },
1243 Runnable::PullRequest => crate::handoff::pr(
1246 root,
1247 feature,
1248 &crate::handoff::PrOptions {
1249 remote: "origin",
1250 base: None,
1251 open: true,
1252 force: false,
1253 github: &crate::github::Ctx::from_env(),
1254 },
1255 ),
1256 }
1257}
1258
1259#[must_use]
1263pub fn allow_always_inline(
1264 root: &Path,
1265 feature: &str,
1266 always: &[crate::substrate::Always],
1267 state: &Path,
1268) -> Vec<String> {
1269 always
1270 .iter()
1271 .map(|a| {
1272 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1273 let run = Runnable::AllowAlways {
1274 task,
1275 call: a.call.clone(),
1276 };
1277 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1278 .message()
1279 .to_owned()
1280 })
1281 .collect()
1282}
1283
1284#[must_use]
1290pub fn answered_at_stop(
1291 root: &Path,
1292 feature: Option<&str>,
1293 task: &str,
1294 answer: &crate::approvals::StopAnswer,
1295 (via, run): (Via, &RunOptions),
1296 state: &Path,
1297) -> CommandResult {
1298 use crate::approvals::StopAnswer;
1299 let task = task.to_owned();
1300 let (what, input) = match answer {
1301 StopAnswer::Done => (Runnable::Done { task }, None),
1302 StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1303 StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1304 };
1305 perform_in(
1306 root,
1307 feature,
1308 Dispatch {
1309 input,
1310 options: RunOptions {
1311 in_place: run.in_place,
1312 session: run.session.clone(),
1313 ..RunOptions::default()
1314 },
1315 ..Dispatch::new(&what, via)
1316 },
1317 Some(state),
1318 )
1319}
1320
1321fn skill(
1326 root: &Path,
1327 feature: Option<&str>,
1328 name: &str,
1329 input: Option<&str>,
1330 carried: &str,
1331 options: &RunOptions,
1332) -> CommandResult {
1333 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1334}
1335
1336fn plan_through(
1337 root: &Path,
1338 description: &str,
1339 source: &speccoder_core::actions::FeatureSource,
1340 through: speccoder_core::actions::Through,
1341 options: &RunOptions,
1342) -> CommandResult {
1343 crate::planning_chain::start(root, description, source, through, options)
1344}
1345
1346fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1347 crate::run(root, feature, task, options)
1348}
1349
1350fn run_feature(
1351 root: &Path,
1352 feature: Option<&str>,
1353 options: &RunOptions,
1354 converge: bool,
1355) -> CommandResult {
1356 crate::run_feature(root, feature, options, converge)
1357}
1358
1359fn bug_fix(
1360 root: &Path,
1361 req: &crate::bug::Request,
1362 via: Via,
1363 state: Option<&Path>,
1364 options: &RunOptions,
1365) -> CommandResult {
1366 crate::bug::bug(root, req, via, state, options)
1367}
1368
1369fn bug_confirm(
1370 root: &Path,
1371 task: &str,
1372 digest: &str,
1373 reject: Option<&str>,
1374 via: Via,
1375 state: Option<&Path>,
1376 options: &RunOptions,
1377) -> CommandResult {
1378 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1379}
1380
1381fn bug_run(
1382 root: &Path,
1383 slug: Option<&str>,
1384 task: Option<&str>,
1385 state: Option<&Path>,
1386 options: &RunOptions,
1387) -> CommandResult {
1388 crate::bug::run(root, slug, task, state, options)
1389}
1390
1391fn quick_change(
1392 root: &Path,
1393 request: &str,
1394 checks: &[String],
1395 run: bool,
1396 via: Via,
1397 options: &RunOptions,
1398 asked: Option<(&str, &Path)>,
1399) -> CommandResult {
1400 crate::quick::change(root, request, checks, run, via, options, asked)
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use speccoder_core::actions::primary;
1406
1407 use super::*;
1408
1409 #[test]
1410 fn every_interface_decides_every_kind() {
1411 let mut taken = 0;
1412 for i in Interface::ALL {
1413 for k in ActionKind::ALL {
1414 if offered(*i, *k) == Offer::Take {
1415 taken += 1;
1416 } else {
1417 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1418 }
1419 }
1420 }
1421 let runnable = Runnable::examples().len();
1425 assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1428 assert_eq!(
1429 offered(Interface::CliFollow, ActionKind::PullRequest),
1430 Offer::Refuse
1431 );
1432 assert_eq!(
1433 offered(Interface::AcpRemedy, ActionKind::FixSetup),
1434 Offer::Take
1435 );
1436 }
1437
1438 fn copied(line: &str) -> bool {
1441 let mut w = line.split_whitespace().skip(1);
1442 matches!(w.next(), Some("eval" | "replay" | "bench"))
1443 || line.contains("--remote <")
1444 || line.split_whitespace().any(|w| w == "-C")
1446 || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1448 && line.split_whitespace().count() == 3
1449 }
1450
1451 const PROSE: &[&str] = &[
1455 "speccoder did not ",
1456 "speccoder cannot ",
1457 "speccoder commits ",
1458 "speccoder could ",
1459 "speccoder failed ",
1460 "speccoder has ",
1461 "speccoder is ",
1462 "speccoder needs ",
1463 "speccoder reads ",
1464 "speccoder works ",
1465 "speccoder does not ",
1466 "speccoder implements ",
1467 "speccoder converges ",
1468 "speccoder runs ",
1469 "speccoder Documentation",
1470 "speccoder — ",
1471 "speccoder ✓",
1472 "speccoder ■",
1473 "speccoder ▸",
1474 "speccoder △",
1475 "speccoder ✗",
1476 "speccoder / ",
1477 "speccoder attempt: ",
1478 "speccoder manifest: ",
1479 "speccoder inspect: ",
1480 "speccoder preset ",
1481 "speccoder bundles",
1482 "speccoder {}: ",
1483 "speccoder {feature}: ",
1484 "speccoder 001-x: ",
1485 "speccoder {} · ",
1486 "speccoder {} --yes",
1488 ];
1489
1490 #[test]
1496 fn no_speccoder_command_is_copy_only() {
1497 let ctx = Context {
1498 feature: Some("001-x"),
1499 task: Some("T001"),
1500 };
1501 let mut seen = 0;
1502 let mut errors = Vec::new();
1503 for e in speccoder_core::codes::CATALOG {
1504 for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1505 if line.starts_with("speccoder ") && !copied(&line) {
1506 let how = runnable_of(&line, ctx).how;
1507 if how.copy_only() {
1508 errors.push(format!("{}: {line} → {how:?}", e.name));
1509 }
1510 seen += 1;
1511 }
1512 }
1513 }
1514 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1515 let mut files = Vec::new();
1516 for dir in [
1517 "src",
1518 "../speccoder-core/src",
1519 "../speccoder-web/src",
1520 "../speccoder/src",
1521 ] {
1522 let mut stack = vec![root.join(dir)];
1523 while let Some(d) = stack.pop() {
1524 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1525 let p = entry.path();
1526 if p.is_dir() {
1527 stack.push(p);
1528 } else if p.extension().is_some_and(|x| x == "rs") {
1529 files.push(p);
1530 }
1531 }
1532 }
1533 }
1534 assert!(files.len() > 100, "{} files", files.len());
1535 for f in files {
1536 let src = std::fs::read_to_string(&f).unwrap_or_default();
1537 let src = src
1539 .split("#[cfg(test)]\nmod tests")
1540 .next()
1541 .unwrap_or_default();
1542 for (at, _) in src.match_indices("\"speccoder ") {
1543 if src[..at].ends_with('\\') {
1545 continue;
1546 }
1547 let mut line = String::new();
1548 let mut chars = src[at + 1..].chars();
1549 while let Some(c) = chars.next() {
1550 match c {
1551 '\\' => line.extend(chars.next()),
1552 '"' => break,
1553 c => line.push(c),
1554 }
1555 }
1556 let mut filled = String::new();
1558 let mut rest = line.as_str();
1559 while let Some(open) = rest.find('{') {
1560 filled.push_str(&rest[..open]);
1561 let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1562 let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1564 filled.push_str(match &rest[open..close] {
1565 _ if suffix => "",
1566 "{task}" | "{t}" => "T001",
1567 "{feature}" | "{f}" => "001-x",
1568 "{command}" | "{verb}" => "run",
1569 _ if filled.ends_with("--through ") => "tasks",
1570 _ => "x",
1571 });
1572 rest = &rest[close.min(rest.len())..];
1573 }
1574 filled.push_str(rest);
1575 if line == "speccoder "
1577 || copied(&filled)
1578 || PROSE.iter().any(|p| line.starts_with(p))
1579 {
1580 continue;
1581 }
1582 let how = runnable_of(&filled, ctx).how;
1583 if how.copy_only() {
1584 errors.push(format!("{}: {filled} → {how:?}", f.display()));
1585 }
1586 seen += 1;
1587 }
1588 }
1589 assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1590 assert!(seen > 100, "{seen} lines seen");
1591 }
1592
1593 #[test]
1594 fn blank_input_is_no_input() {
1595 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1596 let run = Runnable::Reject {
1597 task: "T001".to_owned(),
1598 call: "attempt:T001".to_owned(),
1599 digest: String::new(),
1600 };
1601 let r = perform_in(
1602 dir.path(),
1603 None,
1604 Dispatch {
1605 run: &run,
1606 input: Some(" "),
1607 via: Via::Web,
1608 options: RunOptions::default(),
1609 },
1610 None,
1611 );
1612 assert_eq!(
1613 (r.command(), r.status(), r.message()),
1614 ("job", CommandStatus::UsageError, "a reason is needed")
1615 );
1616 }
1617
1618 #[test]
1619 fn a_project_outside_git_still_gets_a_list() {
1620 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1621 let list = available(dir.path(), None, None);
1622 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1623 assert!(primary(&list).is_none());
1624 }
1625}