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