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::RuleScenario
591 | K::PresetAdd
592 | K::PlanThrough
593 | K::NewProject
594 | K::UndoNewProject
595 | K::CloneRepository
596 | K::OpenSample
597 | K::SampleChecks
598 | K::DeclinePromotion
599 | K::Explore
600 | K::ExploreChoose => Offer::Take,
601 K::Command => Offer::Refuse,
602 },
603 Interface::CliFollow => match kind {
606 K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
608 Offer::Take
609 }
610 K::Approve
611 | K::Reject
612 | K::Accept
613 | K::Skip
614 | K::Nudge
615 | K::Done
616 | K::Hint
617 | K::Rule
618 | K::Review
619 | K::IssueComment
620 | K::QuickChange
621 | K::KeepSetAside
622 | K::DropSetAside
623 | K::FixSetup
624 | K::Feature
625 | K::PullRequest
626 | K::BugFix
627 | K::BugConfirm
628 | K::BugRun
629 | K::BugKeep
630 | K::AllowAlways
631 | K::WidenPolicy
632 | K::DecideBatch
633 | K::OracleTests
634 | K::ApproveAndRun
635 | K::Comment
636 | K::CommentsSend
637 | K::RevisionUndo
638 | K::CodeNudge
639 | K::AcceptanceAt
640 | K::RuleScenario
641 | K::PresetAdd
642 | K::NewProject
643 | K::UndoNewProject
644 | K::CloneRepository
645 | K::OpenSample
646 | K::SampleChecks
647 | K::DeclinePromotion
648 | K::Explore
649 | K::ExploreChoose
650 | K::Command => Offer::Refuse,
651 },
652 Interface::AskProposal => match kind {
655 K::QuickChange | K::BugFix | K::Nudge | K::Review | K::PlanThrough => Offer::Take,
656 K::Skill
657 | K::RunTask
658 | K::RunFeature
659 | K::Converge
660 | K::Commit
661 | K::Approve
662 | K::Reject
663 | K::Accept
664 | K::Skip
665 | K::Done
666 | K::Hint
667 | K::Rule
668 | K::IssueComment
669 | K::KeepSetAside
670 | K::DropSetAside
671 | K::FixSetup
672 | K::Feature
673 | K::PullRequest
674 | K::BugConfirm
675 | K::BugRun
676 | K::BugKeep
677 | K::AllowAlways
678 | K::WidenPolicy
679 | K::DecideBatch
680 | K::OracleTests
681 | K::ApproveAndRun
682 | K::Comment
683 | K::CommentsSend
684 | K::RevisionUndo
685 | K::CodeNudge
686 | K::AcceptanceAt
687 | K::RuleScenario
688 | K::PresetAdd
689 | K::NewProject
690 | K::UndoNewProject
691 | K::CloneRepository
692 | K::OpenSample
693 | K::SampleChecks
694 | K::DeclinePromotion
695 | K::Explore
696 | K::ExploreChoose
697 | K::Command => Offer::Refuse,
698 },
699 Interface::Dashboard => match kind {
702 K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
703 K::Skill
704 | K::RunTask
705 | K::RunFeature
706 | K::Converge
707 | K::Commit
708 | K::Accept
709 | K::Skip
710 | K::Nudge
711 | K::Done
712 | K::Hint
713 | K::Rule
714 | K::Review
715 | K::IssueComment
716 | K::QuickChange
717 | K::KeepSetAside
718 | K::DropSetAside
719 | K::FixSetup
720 | K::PullRequest
721 | K::BugFix
722 | K::BugConfirm
723 | K::BugRun
724 | K::BugKeep
725 | K::WidenPolicy
726 | K::OracleTests
727 | K::ApproveAndRun
728 | K::Comment
729 | K::CommentsSend
730 | K::RevisionUndo
731 | K::CodeNudge
732 | K::AcceptanceAt
733 | K::RuleScenario
734 | K::PresetAdd
735 | K::PlanThrough
736 | K::NewProject
737 | K::UndoNewProject
738 | K::CloneRepository
739 | K::OpenSample
740 | K::SampleChecks
741 | K::DeclinePromotion
742 | K::Explore
743 | K::ExploreChoose
744 | K::Command => Offer::Refuse,
745 },
746 Interface::CliRemedy | Interface::AcpRemedy => match kind {
747 K::FixSetup => Offer::Take,
748 K::Skill
749 | K::RunTask
750 | K::RunFeature
751 | K::Converge
752 | K::Commit
753 | K::Approve
754 | K::Reject
755 | K::Accept
756 | K::Skip
757 | K::Nudge
758 | K::Done
759 | K::Hint
760 | K::Rule
761 | K::Review
762 | K::IssueComment
763 | K::QuickChange
764 | K::KeepSetAside
765 | K::DropSetAside
766 | K::Feature
767 | K::PullRequest
768 | K::BugFix
769 | K::BugConfirm
770 | K::BugRun
771 | K::BugKeep
772 | K::AllowAlways
773 | K::WidenPolicy
774 | K::DecideBatch
775 | K::OracleTests
776 | K::ApproveAndRun
777 | K::Comment
778 | K::CommentsSend
779 | K::RevisionUndo
780 | K::CodeNudge
781 | K::AcceptanceAt
782 | K::RuleScenario
783 | K::PresetAdd
784 | K::PlanThrough
785 | K::NewProject
786 | K::UndoNewProject
787 | K::CloneRepository
788 | K::OpenSample
789 | K::SampleChecks
790 | K::DeclinePromotion
791 | K::Explore
792 | K::ExploreChoose
793 | K::Command => Offer::Refuse,
794 },
795 }
796}
797
798#[derive(Debug)]
802pub struct Dispatch<'a> {
803 pub run: &'a Runnable,
805 pub input: Option<&'a str>,
808 pub via: Via,
810 pub options: RunOptions,
812}
813
814impl<'a> Dispatch<'a> {
815 #[must_use]
817 pub fn new(run: &'a Runnable, via: Via) -> Self {
818 Self {
819 run,
820 input: None,
821 via,
822 options: RunOptions::default(),
823 }
824 }
825}
826
827#[must_use]
830pub fn perform_for(
831 interface: Interface,
832 root: &Path,
833 feature: Option<&str>,
834 d: Dispatch<'_>,
835) -> Option<CommandResult> {
836 (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
837}
838
839#[must_use]
844pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
845 perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
846}
847
848fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
850 RunOptions {
851 agent: agent.cloned().or_else(|| options.agent.clone()),
852 ..options.clone()
853 }
854}
855
856#[must_use]
858pub fn perform_in(
859 root: &Path,
860 feature: Option<&str>,
861 d: Dispatch<'_>,
862 state: Option<&Path>,
863) -> CommandResult {
864 use crate::approvals::{Choice, Verb};
865 let needs = |what: &str| {
866 CommandResult::new(
867 "job",
868 CommandStatus::UsageError,
869 &format!("{what} is needed"),
870 )
871 };
872 let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
873 let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
874 let options = &d.options;
875 let via = d.via;
876 let text = d.input.map(str::trim).filter(|t| !t.is_empty());
877 match d.run {
878 Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
879 Runnable::Run {
880 task: Some(t),
881 agent,
882 } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
883 Runnable::Run { task: None, agent } => {
884 run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
885 }
886 Runnable::Converge => run_feature(root, feature, options, true),
887 Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
888 Runnable::Approve { task, call, digest } => decide(Verb::Decide(
889 Choice::approve(task, text).shown(call, digest),
890 )),
891 Runnable::Reject { task, call, digest } => match text {
892 Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
893 None => needs("a reason"),
894 },
895 Runnable::Accept { task } => match (text, state) {
896 (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
897 (None, _) => needs("a reason"),
898 (_, None) => no_state(),
899 },
900 Runnable::AcceptanceAt {
902 feature: unit,
903 story,
904 task,
905 revoke,
906 } => match state {
907 Some(s) => {
908 crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
909 }
910 None => no_state(),
911 },
912 Runnable::RuleScenario { task, scenario } => match (text, state) {
913 (Some(why), Some(s)) => crate::acceptance_at::rule_tests_wrong(
914 root,
915 feature,
916 task,
917 scenario.as_deref(),
918 why,
919 via,
920 s,
921 ),
922 (None, _) => needs("a reason"),
923 (_, None) => no_state(),
924 },
925 Runnable::PresetAdd { update } => match state {
927 Some(s) => crate::preset::add(root, *update, true, s),
928 None => no_state(),
929 },
930 Runnable::Skip { task } => match text {
931 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
932 None => needs("a reason"),
933 },
934 Runnable::Nudge { task } => match (text, state) {
935 (Some(note), Some(s)) => {
936 crate::controls::nudge_with(root, feature, task, Some(note), s)
937 }
938 (None, _) => needs("a note"),
939 (_, None) => no_state(),
940 },
941 Runnable::Done { task } => match state {
944 Some(s) => crate::controls::accept_in_session(
945 root,
946 feature,
947 task,
948 text.unwrap_or(DONE_REASON),
949 s,
950 options,
951 ),
952 None => no_state(),
953 },
954 Runnable::Hint { task } => match (text, state) {
956 (Some(note), Some(s)) => {
957 let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
958 if noted.status() == CommandStatus::Ok {
959 run_task(root, feature, task, options)
960 } else {
961 noted
962 }
963 }
964 (None, _) => needs("a hint"),
965 (_, None) => no_state(),
966 },
967 Runnable::Rule { task } => match (text, state) {
968 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
969 (None, _) => needs("a reason"),
970 (_, None) => no_state(),
971 },
972 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
973 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
974 ("edit" | "edit-approve", Some(id), Some(t)) => {
975 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
976 }
977 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
978 _ => crate::review::decide(root, feature, ids, verdict, text, via),
979 },
980 Runnable::IssueComment { issue, assign } => crate::github::comment(
981 &crate::github::Ctx::from_env(),
982 root,
983 feature,
984 &issue.to_string(),
985 *assign,
986 ),
987 Runnable::QuickChange {
988 request,
989 checks,
990 run,
991 asked,
992 } => quick_change(
993 root,
994 request,
995 checks,
996 *run,
997 via,
998 options,
999 asked.as_deref().zip(state),
1000 ),
1001 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
1002 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
1003 Runnable::FixSetup => crate::code::fix_setup(root, feature),
1004 Runnable::Feature => match text {
1005 Some(name) => crate::current::switch(root, name),
1006 None => needs("a feature"),
1007 },
1008 Runnable::BugFix {
1009 symptom,
1010 expected,
1011 unchanged,
1012 issue,
1013 tests,
1014 candidate,
1015 slug,
1016 run,
1017 asked,
1018 } => bug_fix(
1019 root,
1020 &crate::bug::Request {
1021 asked: asked.clone(),
1022 ..crate::bug::Request::of(
1023 symptom,
1024 expected,
1025 unchanged,
1026 *issue,
1027 tests,
1028 candidate.as_deref(),
1029 slug.as_deref(),
1030 *run,
1031 )
1032 },
1033 via,
1034 state,
1035 options,
1036 ),
1037 Runnable::BugConfirm {
1038 task,
1039 digest,
1040 reject,
1041 } => match (*reject, text) {
1042 (true, None) => needs("a reason"),
1043 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1044 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1045 },
1046 Runnable::BugRun { slug, task } => {
1047 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1048 }
1049 Runnable::BugKeep { task } => match text {
1050 Some(why) => crate::bug::keep(root, task, why, state),
1051 None => needs("a reason"),
1052 },
1053 Runnable::AllowAlways { task, call } => {
1054 crate::approvals::allow_always(root, feature, task, call, state, via)
1055 }
1056 Runnable::WidenPolicy {
1057 key,
1058 prefix,
1059 digest,
1060 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1061 Runnable::DecideBatch {
1062 run,
1063 verdict,
1064 calls,
1065 digest,
1066 resume_asking,
1067 } => {
1068 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1069 if !approve && text.is_none() {
1070 return needs("a reason");
1071 }
1072 crate::approvals::decide_batch(
1073 root,
1074 feature,
1075 crate::approvals::BatchChoice {
1076 run,
1077 approve,
1078 calls,
1079 digest,
1080 resume_asking: *resume_asking,
1081 reason: text,
1082 },
1083 state,
1084 via,
1085 )
1086 }
1087 Runnable::OracleTests {
1088 req,
1089 op,
1090 path,
1091 scenario,
1092 reading,
1093 } => crate::oracle::tests(
1094 root,
1095 feature,
1096 &crate::oracle::Request {
1097 req,
1098 op: *op,
1099 path: path.as_deref(),
1100 scenario: scenario.as_deref(),
1101 reading: *reading,
1102 text: d.input.filter(|t| !t.trim().is_empty()),
1103 },
1104 via,
1105 state,
1106 options,
1107 ),
1108 Runnable::ApproveAndRun {
1109 mode,
1110 grants,
1111 sheet,
1112 } => crate::review::approve_and_run(
1113 root,
1114 feature,
1115 *mode,
1116 grants,
1117 sheet.as_deref(),
1118 via,
1119 state,
1120 None,
1121 options,
1122 ),
1123 Runnable::Comment { op, anchor, id } => crate::comments::comment(
1124 root,
1125 feature,
1126 *op,
1127 anchor.as_ref(),
1128 id.as_deref(),
1129 text,
1130 via,
1131 state,
1132 ),
1133 Runnable::CommentsSend { ids } => {
1134 crate::comments::send(root, feature, ids, via, state, options)
1135 }
1136 Runnable::Explore {
1137 idea,
1138 skill,
1139 resume,
1140 } => crate::explore::explore(
1141 root,
1142 text.unwrap_or(idea),
1143 skill.as_deref(),
1144 resume.as_deref(),
1145 options,
1146 ),
1147 Runnable::ExploreChoose {
1148 session,
1149 approach,
1150 constraints,
1151 questions,
1152 flag,
1153 } => crate::explore::choose(
1154 root,
1155 state,
1156 session,
1157 *approach,
1158 constraints,
1159 questions,
1160 via,
1161 if *flag { "flag" } else { "prompt" },
1162 ),
1163 Runnable::RevisionUndo { turn } => {
1164 crate::comments::undo(root, feature, turn.as_deref(), state)
1165 }
1166 Runnable::CodeNudge { task, comments } => {
1167 crate::comments::code_nudge(root, feature, task, comments, state)
1168 }
1169 Runnable::PlanThrough {
1172 from:
1173 speccoder_core::actions::PlanFrom::Create {
1174 description,
1175 source,
1176 },
1177 through,
1178 } => {
1179 let description = if description.trim().is_empty() {
1180 text.unwrap_or_default()
1181 } else {
1182 description.as_str()
1183 };
1184 match source {
1185 speccoder_core::actions::FeatureSource::Promotion {
1188 task,
1189 conversations,
1190 } => {
1191 let Some(s) = state else { return no_state() };
1192 let input = crate::ladder::promotion_input(root, s, description, conversations);
1193 let made = plan_through(root, &input, source, *through, options);
1194 crate::ladder::promoted(
1195 root,
1196 s,
1197 &made,
1198 task,
1199 conversations,
1200 via,
1201 options.progress.as_ref(),
1202 );
1203 made
1204 }
1205 speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1208 let Some(s) = state else { return no_state() };
1209 if let Err(refused) =
1210 crate::explore::verify_start(root, s, session, digest, description)
1211 {
1212 return *refused;
1213 }
1214 let made = plan_through(root, description, source, *through, options);
1215 crate::explore::explored(root, s, &made, session, digest, via);
1216 made
1217 }
1218 _ => plan_through(root, description, source, *through, options),
1219 }
1220 }
1221 Runnable::PlanThrough {
1222 from: speccoder_core::actions::PlanFrom::Continue,
1223 through,
1224 } => crate::planning_chain::resume(root, feature, *through, options),
1225 Runnable::NewProject {
1227 target,
1228 choices,
1229 plan_digest,
1230 } => match state {
1231 Some(s) => crate::new_project::perform(
1232 Path::new(target),
1233 choices,
1234 plan_digest,
1235 via,
1236 s,
1237 options,
1238 ),
1239 None => no_state(),
1240 },
1241 Runnable::UndoNewProject { commit } => match state {
1242 Some(s) => crate::new_project::undo(root, commit, via, s, options),
1243 None => no_state(),
1244 },
1245 Runnable::CloneRepository { repo, target } => {
1247 crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1248 }
1249 Runnable::OpenSample { fresh } => match state {
1250 Some(s) => crate::sample::open(s, *fresh),
1251 None => no_state(),
1252 },
1253 Runnable::SampleChecks => match state {
1254 Some(s) => crate::sample::run_checks(s, root),
1255 None => no_state(),
1256 },
1257 Runnable::DeclinePromotion { task } => match state {
1258 Some(s) => crate::ladder::decline_task(root, s, task),
1259 None => no_state(),
1260 },
1261 Runnable::PullRequest => crate::handoff::pr(
1264 root,
1265 feature,
1266 &crate::handoff::PrOptions {
1267 remote: "origin",
1268 base: None,
1269 open: true,
1270 force: false,
1271 github: &crate::github::Ctx::from_env(),
1272 },
1273 ),
1274 }
1275}
1276
1277#[must_use]
1281pub fn allow_always_inline(
1282 root: &Path,
1283 feature: &str,
1284 always: &[crate::substrate::Always],
1285 state: &Path,
1286) -> Vec<String> {
1287 always
1288 .iter()
1289 .map(|a| {
1290 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1291 let run = Runnable::AllowAlways {
1292 task,
1293 call: a.call.clone(),
1294 };
1295 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1296 .message()
1297 .to_owned()
1298 })
1299 .collect()
1300}
1301
1302#[must_use]
1308pub fn answered_at_stop(
1309 root: &Path,
1310 feature: Option<&str>,
1311 task: &str,
1312 answer: &crate::approvals::StopAnswer,
1313 (via, run): (Via, &RunOptions),
1314 state: &Path,
1315) -> CommandResult {
1316 use crate::approvals::StopAnswer;
1317 let task = task.to_owned();
1318 let (what, input) = match answer {
1319 StopAnswer::Done => (Runnable::Done { task }, None),
1320 StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1321 StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1322 };
1323 perform_in(
1324 root,
1325 feature,
1326 Dispatch {
1327 input,
1328 options: RunOptions {
1329 in_place: run.in_place,
1330 session: run.session.clone(),
1331 ..RunOptions::default()
1332 },
1333 ..Dispatch::new(&what, via)
1334 },
1335 Some(state),
1336 )
1337}
1338
1339fn skill(
1344 root: &Path,
1345 feature: Option<&str>,
1346 name: &str,
1347 input: Option<&str>,
1348 carried: &str,
1349 options: &RunOptions,
1350) -> CommandResult {
1351 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1352}
1353
1354fn plan_through(
1355 root: &Path,
1356 description: &str,
1357 source: &speccoder_core::actions::FeatureSource,
1358 through: speccoder_core::actions::Through,
1359 options: &RunOptions,
1360) -> CommandResult {
1361 crate::planning_chain::start(root, description, source, through, options)
1362}
1363
1364fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1365 crate::run(root, feature, task, options)
1366}
1367
1368fn run_feature(
1369 root: &Path,
1370 feature: Option<&str>,
1371 options: &RunOptions,
1372 converge: bool,
1373) -> CommandResult {
1374 crate::run_feature(root, feature, options, converge)
1375}
1376
1377fn bug_fix(
1378 root: &Path,
1379 req: &crate::bug::Request,
1380 via: Via,
1381 state: Option<&Path>,
1382 options: &RunOptions,
1383) -> CommandResult {
1384 crate::bug::bug(root, req, via, state, options)
1385}
1386
1387fn bug_confirm(
1388 root: &Path,
1389 task: &str,
1390 digest: &str,
1391 reject: Option<&str>,
1392 via: Via,
1393 state: Option<&Path>,
1394 options: &RunOptions,
1395) -> CommandResult {
1396 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1397}
1398
1399fn bug_run(
1400 root: &Path,
1401 slug: Option<&str>,
1402 task: Option<&str>,
1403 state: Option<&Path>,
1404 options: &RunOptions,
1405) -> CommandResult {
1406 crate::bug::run(root, slug, task, state, options)
1407}
1408
1409fn quick_change(
1410 root: &Path,
1411 request: &str,
1412 checks: &[String],
1413 run: bool,
1414 via: Via,
1415 options: &RunOptions,
1416 asked: Option<(&str, &Path)>,
1417) -> CommandResult {
1418 crate::quick::change(root, request, checks, run, via, options, asked)
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use speccoder_core::actions::primary;
1424
1425 use super::*;
1426
1427 #[test]
1428 fn every_interface_decides_every_kind() {
1429 let mut taken = 0;
1430 for i in Interface::ALL {
1431 for k in ActionKind::ALL {
1432 if offered(*i, *k) == Offer::Take {
1433 taken += 1;
1434 } else {
1435 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1436 }
1437 }
1438 }
1439 let runnable = Runnable::examples().len();
1443 assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1446 assert_eq!(
1447 offered(Interface::CliFollow, ActionKind::PullRequest),
1448 Offer::Refuse
1449 );
1450 assert_eq!(
1451 offered(Interface::AcpRemedy, ActionKind::FixSetup),
1452 Offer::Take
1453 );
1454 }
1455
1456 fn copied(line: &str) -> bool {
1459 let mut w = line.split_whitespace().skip(1);
1460 matches!(w.next(), Some("eval" | "replay" | "bench"))
1461 || line.contains("--remote <")
1462 || line.split_whitespace().any(|w| w == "-C")
1464 || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1466 && line.split_whitespace().count() == 3
1467 }
1468
1469 const PROSE: &[&str] = &[
1473 "speccoder did not ",
1474 "speccoder cannot ",
1475 "speccoder commits ",
1476 "speccoder could ",
1477 "speccoder failed ",
1478 "speccoder has ",
1479 "speccoder is ",
1480 "speccoder needs ",
1481 "speccoder reads ",
1482 "speccoder works ",
1483 "speccoder does not ",
1484 "speccoder implements ",
1485 "speccoder converges ",
1486 "speccoder runs ",
1487 "speccoder Documentation",
1488 "speccoder — ",
1489 "speccoder ✓",
1490 "speccoder ■",
1491 "speccoder ▸",
1492 "speccoder △",
1493 "speccoder ✗",
1494 "speccoder / ",
1495 "speccoder attempt: ",
1496 "speccoder manifest: ",
1497 "speccoder inspect: ",
1498 "speccoder preset ",
1499 "speccoder bundles",
1500 "speccoder {}: ",
1501 "speccoder {feature}: ",
1502 "speccoder 001-x: ",
1503 "speccoder {} · ",
1504 "speccoder {} --yes",
1506 ];
1507
1508 #[test]
1514 fn no_speccoder_command_is_copy_only() {
1515 let ctx = Context {
1516 feature: Some("001-x"),
1517 task: Some("T001"),
1518 };
1519 let mut seen = 0;
1520 let mut errors = Vec::new();
1521 for e in speccoder_core::codes::CATALOG {
1522 for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1523 if line.starts_with("speccoder ") && !copied(&line) {
1524 let how = runnable_of(&line, ctx).how;
1525 if how.copy_only() {
1526 errors.push(format!("{}: {line} → {how:?}", e.name));
1527 }
1528 seen += 1;
1529 }
1530 }
1531 }
1532 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1533 let mut files = Vec::new();
1534 for dir in [
1535 "src",
1536 "../speccoder-core/src",
1537 "../speccoder-web/src",
1538 "../speccoder/src",
1539 ] {
1540 let mut stack = vec![root.join(dir)];
1541 while let Some(d) = stack.pop() {
1542 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1543 let p = entry.path();
1544 if p.is_dir() {
1545 stack.push(p);
1546 } else if p.extension().is_some_and(|x| x == "rs") {
1547 files.push(p);
1548 }
1549 }
1550 }
1551 }
1552 assert!(files.len() > 100, "{} files", files.len());
1553 for f in files {
1554 let src = std::fs::read_to_string(&f).unwrap_or_default();
1555 let src = src
1557 .split("#[cfg(test)]\nmod tests")
1558 .next()
1559 .unwrap_or_default();
1560 for (at, _) in src.match_indices("\"speccoder ") {
1561 if src[..at].ends_with('\\') {
1563 continue;
1564 }
1565 let mut line = String::new();
1566 let mut chars = src[at + 1..].chars();
1567 while let Some(c) = chars.next() {
1568 match c {
1569 '\\' => line.extend(chars.next()),
1570 '"' => break,
1571 c => line.push(c),
1572 }
1573 }
1574 let mut filled = String::new();
1576 let mut rest = line.as_str();
1577 while let Some(open) = rest.find('{') {
1578 filled.push_str(&rest[..open]);
1579 let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1580 let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1582 filled.push_str(match &rest[open..close] {
1583 _ if suffix => "",
1584 "{task}" | "{t}" => "T001",
1585 "{feature}" | "{f}" => "001-x",
1586 "{command}" | "{verb}" => "run",
1587 _ if filled.ends_with("--through ") => "tasks",
1588 _ => "x",
1589 });
1590 rest = &rest[close.min(rest.len())..];
1591 }
1592 filled.push_str(rest);
1593 if line == "speccoder "
1595 || copied(&filled)
1596 || PROSE.iter().any(|p| line.starts_with(p))
1597 {
1598 continue;
1599 }
1600 let how = runnable_of(&filled, ctx).how;
1601 if how.copy_only() {
1602 errors.push(format!("{}: {filled} → {how:?}", f.display()));
1603 }
1604 seen += 1;
1605 }
1606 }
1607 assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1608 assert!(seen > 100, "{seen} lines seen");
1609 }
1610
1611 #[test]
1612 fn blank_input_is_no_input() {
1613 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1614 let run = Runnable::Reject {
1615 task: "T001".to_owned(),
1616 call: "attempt:T001".to_owned(),
1617 digest: String::new(),
1618 };
1619 let r = perform_in(
1620 dir.path(),
1621 None,
1622 Dispatch {
1623 run: &run,
1624 input: Some(" "),
1625 via: Via::Web,
1626 options: RunOptions::default(),
1627 },
1628 None,
1629 );
1630 assert_eq!(
1631 (r.command(), r.status(), r.message()),
1632 ("job", CommandStatus::UsageError, "a reason is needed")
1633 );
1634 }
1635
1636 #[test]
1637 fn a_project_outside_git_still_gets_a_list() {
1638 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1639 let list = available(dir.path(), None, None);
1640 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1641 assert!(primary(&list).is_none());
1642 }
1643}