1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum Runnable {
12 Skill {
14 name: String,
16 arguments: String,
18 },
19 Run {
21 task: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 agent: Option<String>,
27 },
28 Converge,
31 Commit {
33 paths: Vec<String>,
35 },
36 Approve {
39 task: String,
41 call: String,
43 digest: String,
45 },
46 Reject {
48 task: String,
50 call: String,
52 digest: String,
54 },
55 Accept {
57 task: String,
59 },
60 Skip {
62 task: String,
64 },
65 Nudge {
67 task: String,
69 },
70 Done {
73 task: String,
75 },
76 Hint {
78 task: String,
80 },
81 Rule {
83 task: String,
85 },
86 Review {
89 ids: Vec<String>,
91 verdict: String,
95 },
96 IssueComment {
98 issue: u64,
100 assign: bool,
102 },
103 QuickChange {
105 request: String,
107 checks: Vec<String>,
109 run: bool,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 asked: Option<String>,
114 },
115 KeepSetAside {
118 task: String,
120 },
121 DropSetAside {
123 task: String,
125 },
126 FixSetup,
129 Feature,
131 PullRequest,
133 BugFix {
136 symptom: String,
138 expected: Vec<String>,
140 unchanged: Vec<String>,
142 issue: Option<u64>,
144 tests: Vec<String>,
147 candidate: Option<String>,
149 slug: Option<String>,
151 run: bool,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 asked: Option<String>,
156 },
157 BugConfirm {
160 task: String,
162 digest: String,
164 reject: bool,
166 },
167 BugKeep {
170 task: String,
172 },
173 BugRun {
175 slug: Option<String>,
177 task: Option<String>,
179 },
180 AllowAlways {
183 task: String,
185 call: String,
187 },
188 WidenPolicy {
191 key: PolicyList,
193 prefix: Vec<String>,
195 digest: String,
197 },
198 DecideBatch {
200 run: String,
202 verdict: BatchVerdict,
204 calls: Vec<String>,
206 digest: String,
208 resume_asking: bool,
210 },
211 OracleTests {
214 req: String,
216 op: crate::oracle::OracleOp,
218 path: Option<String>,
220 scenario: Option<String>,
222 reading: Option<crate::oracle::Reading>,
224 },
225 ApproveAndRun {
228 mode: crate::oracle::RunMode,
230 grants: Vec<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 sheet: Option<String>,
236 },
237 Comment {
239 op: CommentOp,
241 anchor: Option<crate::oracle::CommentAnchor>,
243 id: Option<String>,
245 },
246 CommentsSend {
248 ids: Vec<String>,
251 },
252 RevisionUndo {
254 turn: Option<String>,
256 },
257 CodeNudge {
259 task: String,
261 comments: Vec<crate::oracle::CodeComment>,
263 },
264 AcceptanceAt {
268 feature: String,
270 story: String,
272 task: String,
274 revoke: bool,
276 },
277 CheckStory {
280 story: String,
282 },
283 RuleScenario {
287 task: String,
289 scenario: Option<String>,
291 },
292 AcceptUntestable {
296 scenario: String,
298 },
299 PresetAdd {
302 update: bool,
304 },
305 PlanThrough {
309 from: PlanFrom,
311 through: Through,
313 },
314 NewProject {
317 target: String,
319 choices: crate::new_project::NewChoices,
321 plan_digest: String,
323 },
324 UndoNewProject {
327 commit: String,
329 },
330 CloneRepository {
332 repo: String,
334 target: String,
336 },
337 OpenSample {
340 fresh: bool,
342 },
343 SampleChecks,
346 DeclinePromotion {
349 task: String,
351 },
352 Explore {
355 idea: String,
357 #[serde(default)]
359 skill: Option<String>,
360 #[serde(default)]
362 resume: Option<String>,
363 },
364 ExploreChoose {
367 session: String,
369 approach: usize,
371 #[serde(default)]
373 constraints: Vec<String>,
374 #[serde(default)]
376 questions: Vec<String>,
377 #[serde(default)]
379 flag: bool,
380 },
381}
382
383#[derive(
385 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
386)]
387#[serde(rename_all = "snake_case")]
388pub enum Through {
389 Spec,
391 Plan,
393 Tasks,
395 Analyze,
397}
398
399impl Through {
400 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
402
403 #[must_use]
405 pub const fn as_str(self) -> &'static str {
406 match self {
407 Self::Spec => "spec",
408 Self::Plan => "plan",
409 Self::Tasks => "tasks",
410 Self::Analyze => "analyze",
411 }
412 }
413
414 #[must_use]
416 pub fn parse(s: &str) -> Option<Self> {
417 Self::ALL.into_iter().find(|t| t.as_str() == s)
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
424#[serde(tag = "kind", rename_all = "snake_case")]
425pub enum FeatureSource {
426 Description,
428 Issue {
430 number: u64,
432 },
433 Promotion {
436 task: String,
438 #[serde(default)]
440 conversations: Vec<String>,
441 },
442 Explore {
444 session: String,
446 digest: String,
448 },
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
453#[serde(tag = "kind", rename_all = "snake_case")]
454pub enum PlanFrom {
455 Create {
457 description: String,
459 source: FeatureSource,
461 },
462 Continue,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
468#[serde(rename_all = "snake_case")]
469pub enum CommentOp {
470 Add,
472 Delete,
474 Keep,
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
480#[serde(rename_all = "snake_case")]
481pub enum PolicyList {
482 CommandsPrefix,
484 NetworkPrefix,
486}
487
488impl PolicyList {
489 #[must_use]
491 pub const fn as_str(self) -> &'static str {
492 match self {
493 Self::CommandsPrefix => "commands_prefix",
494 Self::NetworkPrefix => "network_prefix",
495 }
496 }
497
498 #[must_use]
500 pub const fn key(self) -> &'static str {
501 match self {
502 Self::CommandsPrefix => "commands.allow_prefix",
503 Self::NetworkPrefix => "sandbox.network_allow_prefix",
504 }
505 }
506
507 #[must_use]
509 pub fn of_key(key: &str) -> Option<Self> {
510 match key {
511 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
512 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
513 _ => None,
514 }
515 }
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
520#[serde(rename_all = "snake_case")]
521pub enum BatchVerdict {
522 Approve,
524 Reject,
526}
527
528#[derive(
534 Debug,
535 Clone,
536 Copy,
537 PartialEq,
538 Eq,
539 Hash,
540 PartialOrd,
541 Ord,
542 Serialize,
543 Deserialize,
544 schemars::JsonSchema,
545)]
546#[serde(rename_all = "snake_case")]
547pub enum ActionKind {
548 Skill,
550 RunTask,
552 RunFeature,
554 Converge,
556 Commit,
558 Approve,
560 Reject,
562 Accept,
564 Skip,
566 Nudge,
568 Done,
570 Hint,
572 Rule,
574 Review,
576 IssueComment,
578 QuickChange,
580 KeepSetAside,
582 DropSetAside,
584 FixSetup,
586 Feature,
588 PullRequest,
590 BugFix,
592 BugConfirm,
594 BugRun,
596 BugKeep,
598 AllowAlways,
600 WidenPolicy,
602 DecideBatch,
604 OracleTests,
606 ApproveAndRun,
608 Comment,
610 CommentsSend,
612 RevisionUndo,
614 CodeNudge,
616 AcceptanceAt,
618 RuleScenario,
620 CheckStory,
622 AcceptUntestable,
624 PresetAdd,
626 PlanThrough,
628 NewProject,
630 UndoNewProject,
632 CloneRepository,
634 OpenSample,
636 SampleChecks,
638 DeclinePromotion,
640 Explore,
642 ExploreChoose,
644 Command,
646}
647
648impl ActionKind {
649 pub const ALL: &'static [Self] = &[
651 Self::Skill,
652 Self::RunTask,
653 Self::RunFeature,
654 Self::Converge,
655 Self::Commit,
656 Self::Approve,
657 Self::Reject,
658 Self::Accept,
659 Self::Skip,
660 Self::Nudge,
661 Self::Done,
662 Self::Hint,
663 Self::Rule,
664 Self::Review,
665 Self::IssueComment,
666 Self::QuickChange,
667 Self::KeepSetAside,
668 Self::DropSetAside,
669 Self::FixSetup,
670 Self::Feature,
671 Self::PullRequest,
672 Self::BugFix,
673 Self::BugConfirm,
674 Self::BugRun,
675 Self::BugKeep,
676 Self::AllowAlways,
677 Self::WidenPolicy,
678 Self::DecideBatch,
679 Self::OracleTests,
680 Self::ApproveAndRun,
681 Self::Comment,
682 Self::CommentsSend,
683 Self::RevisionUndo,
684 Self::CodeNudge,
685 Self::AcceptanceAt,
686 Self::RuleScenario,
687 Self::CheckStory,
688 Self::AcceptUntestable,
689 Self::PresetAdd,
690 Self::PlanThrough,
691 Self::NewProject,
692 Self::UndoNewProject,
693 Self::CloneRepository,
694 Self::OpenSample,
695 Self::SampleChecks,
696 Self::DeclinePromotion,
697 Self::Explore,
698 Self::ExploreChoose,
699 Self::Command,
700 ];
701
702 #[must_use]
704 pub const fn as_str(self) -> &'static str {
705 match self {
706 Self::Skill => "skill",
707 Self::RunTask => "run_task",
708 Self::RunFeature => "run_feature",
709 Self::Converge => "converge",
710 Self::Commit => "commit",
711 Self::Approve => "approve",
712 Self::Reject => "reject",
713 Self::Accept => "accept",
714 Self::Skip => "skip",
715 Self::Nudge => "nudge",
716 Self::Done => "done",
717 Self::Hint => "hint",
718 Self::Rule => "rule",
719 Self::Review => "review",
720 Self::IssueComment => "issue_comment",
721 Self::QuickChange => "quick_change",
722 Self::KeepSetAside => "keep_set_aside",
723 Self::DropSetAside => "drop_set_aside",
724 Self::FixSetup => "fix_setup",
725 Self::Feature => "feature",
726 Self::PullRequest => "pull_request",
727 Self::BugFix => "bug_fix",
728 Self::BugConfirm => "bug_confirm",
729 Self::BugRun => "bug_run",
730 Self::BugKeep => "bug_keep",
731 Self::AllowAlways => "allow_always",
732 Self::WidenPolicy => "widen_policy",
733 Self::DecideBatch => "decide_batch",
734 Self::OracleTests => "oracle_tests",
735 Self::ApproveAndRun => "approve_and_run",
736 Self::Comment => "comment",
737 Self::CommentsSend => "comments_send",
738 Self::RevisionUndo => "revision_undo",
739 Self::CodeNudge => "code_nudge",
740 Self::AcceptanceAt => "acceptance_at",
741 Self::RuleScenario => "rule_scenario",
742 Self::CheckStory => "check_story",
743 Self::AcceptUntestable => "accept_untestable",
744 Self::PresetAdd => "preset_add",
745 Self::PlanThrough => "plan_through",
746 Self::NewProject => "new_project",
747 Self::UndoNewProject => "undo_new_project",
748 Self::CloneRepository => "clone_repository",
749 Self::OpenSample => "open_sample",
750 Self::SampleChecks => "sample_checks",
751 Self::DeclinePromotion => "decline_promotion",
752 Self::Explore => "explore",
753 Self::ExploreChoose => "explore_choose",
754 Self::Command => "command",
755 }
756 }
757
758 #[must_use]
761 pub fn example(self) -> Option<Runnable> {
762 let task = || "T001".to_owned();
763 Some(match self {
764 Self::Skill => Runnable::Skill {
765 name: "plan".to_owned(),
766 arguments: String::new(),
767 },
768 Self::RunTask => Runnable::Run {
769 task: Some(task()),
770 agent: None,
771 },
772 Self::RunFeature => Runnable::Run {
773 task: None,
774 agent: None,
775 },
776 Self::Converge => Runnable::Converge,
777 Self::Commit => Runnable::Commit {
778 paths: vec!["plan.md".to_owned()],
779 },
780 Self::Approve => Runnable::Approve {
781 task: task(),
782 call: "0f3c".to_owned(),
783 digest: "d1".to_owned(),
784 },
785 Self::Reject => Runnable::Reject {
786 task: task(),
787 call: "0f3c".to_owned(),
788 digest: "d1".to_owned(),
789 },
790 Self::Accept => Runnable::Accept { task: task() },
791 Self::Skip => Runnable::Skip { task: task() },
792 Self::Nudge => Runnable::Nudge { task: task() },
793 Self::Done => Runnable::Done { task: task() },
794 Self::Hint => Runnable::Hint { task: task() },
795 Self::Rule => Runnable::Rule { task: task() },
796 Self::Review => Runnable::Review {
797 ids: vec!["FR-001".to_owned()],
798 verdict: "approve".to_owned(),
799 },
800 Self::IssueComment => Runnable::IssueComment {
801 issue: 42,
802 assign: false,
803 },
804 Self::QuickChange => Runnable::QuickChange {
805 request: "a change".to_owned(),
806 checks: Vec::new(),
807 run: false,
808 asked: None,
809 },
810 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
811 Self::DropSetAside => Runnable::DropSetAside { task: task() },
812 Self::FixSetup => Runnable::FixSetup,
813 Self::Feature => Runnable::Feature,
814 Self::PullRequest => Runnable::PullRequest,
815 Self::BugFix => Runnable::BugFix {
816 symptom: "saving an empty title crashes".to_owned(),
817 expected: vec!["an empty title is refused".to_owned()],
818 unchanged: Vec::new(),
819 issue: None,
820 tests: Vec::new(),
821 candidate: None,
822 slug: None,
823 run: false,
824 asked: None,
825 },
826 Self::BugConfirm => Runnable::BugConfirm {
827 task: "007-bugfixes/T001".to_owned(),
828 digest: "sha256:0".to_owned(),
829 reject: false,
830 },
831 Self::BugRun => Runnable::BugRun {
832 slug: None,
833 task: Some("007-bugfixes/T001".to_owned()),
834 },
835 Self::BugKeep => Runnable::BugKeep {
836 task: "007-bugfixes/T001".to_owned(),
837 },
838 Self::AllowAlways => Runnable::AllowAlways {
839 task: task(),
840 call: "0f3c".to_owned(),
841 },
842 Self::WidenPolicy => Runnable::WidenPolicy {
843 key: PolicyList::CommandsPrefix,
844 prefix: vec!["npm".to_owned(), "run".to_owned()],
845 digest: "d1".to_owned(),
846 },
847 Self::DecideBatch => Runnable::DecideBatch {
848 run: "run-1-0a0b0c0d".to_owned(),
849 verdict: BatchVerdict::Approve,
850 calls: vec!["0f3c".to_owned()],
851 digest: "d1".to_owned(),
852 resume_asking: false,
853 },
854 Self::OracleTests => Runnable::OracleTests {
855 req: "US1".to_owned(),
856 op: crate::oracle::OracleOp::Write,
857 path: None,
858 scenario: None,
859 reading: None,
860 },
861 Self::ApproveAndRun => Runnable::ApproveAndRun {
862 mode: crate::oracle::RunMode::Ask,
863 grants: Vec::new(),
864 sheet: None,
865 },
866 Self::Comment => Runnable::Comment {
867 op: CommentOp::Delete,
868 anchor: None,
869 id: Some("c1".to_owned()),
870 },
871 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
872 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
873 Self::CodeNudge => Runnable::CodeNudge {
874 task: task(),
875 comments: vec![crate::oracle::CodeComment {
876 path: "src/lib.rs".to_owned(),
877 line: 1,
878 quote: "pub fn ok() {}".to_owned(),
879 text: "name it".to_owned(),
880 }],
881 },
882 Self::AcceptanceAt => Runnable::AcceptanceAt {
883 feature: "001-greet-function".to_owned(),
884 story: "US1".to_owned(),
885 task: "T003".to_owned(),
886 revoke: false,
887 },
888 Self::CheckStory => Runnable::CheckStory {
889 story: "US3".to_owned(),
890 },
891 Self::AcceptUntestable => Runnable::AcceptUntestable {
892 scenario: "US2-2".to_owned(),
893 },
894 Self::RuleScenario => Runnable::RuleScenario {
895 task: "T021".to_owned(),
896 scenario: Some("US3-2".to_owned()),
897 },
898 Self::PresetAdd => Runnable::PresetAdd { update: false },
899 Self::PlanThrough => Runnable::PlanThrough {
900 from: PlanFrom::Create {
901 description: "Users can export a report as CSV".to_owned(),
902 source: FeatureSource::Description,
903 },
904 through: Through::Tasks,
905 },
906 Self::NewProject => Runnable::NewProject {
907 target: "/tmp/md2csv".to_owned(),
908 choices: crate::new_project::NewChoices {
909 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
910 ..crate::new_project::NewChoices::default()
911 },
912 plan_digest: "sha256:0".to_owned(),
913 },
914 Self::UndoNewProject => Runnable::UndoNewProject {
915 commit: "1a2b3c4".to_owned(),
916 },
917 Self::CloneRepository => Runnable::CloneRepository {
918 repo: "octo/hello".to_owned(),
919 target: "/tmp/hello".to_owned(),
920 },
921 Self::OpenSample => Runnable::OpenSample { fresh: false },
922 Self::SampleChecks => Runnable::SampleChecks,
923 Self::DeclinePromotion => Runnable::DeclinePromotion {
924 task: "004-quick-changes/T002".to_owned(),
925 },
926 Self::Explore => Runnable::Explore {
927 idea: "export reports as CSV".to_owned(),
928 skill: None,
929 resume: None,
930 },
931 Self::ExploreChoose => Runnable::ExploreChoose {
932 session: "ex-1a2b3c4d5e6f".to_owned(),
933 approach: 2,
934 constraints: vec!["no new dependency".to_owned()],
935 questions: Vec::new(),
936 flag: false,
937 },
938 Self::Command => return None,
939 })
940 }
941}
942
943impl Runnable {
944 #[must_use]
946 pub fn examples() -> Vec<Self> {
947 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
948 }
949
950 #[must_use]
952 pub const fn kind(&self) -> ActionKind {
953 match self {
954 Self::Skill { .. } => ActionKind::Skill,
955 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
956 Self::Run { task: None, .. } => ActionKind::RunFeature,
957 Self::Converge => ActionKind::Converge,
958 Self::Commit { .. } => ActionKind::Commit,
959 Self::Approve { .. } => ActionKind::Approve,
960 Self::Reject { .. } => ActionKind::Reject,
961 Self::Accept { .. } => ActionKind::Accept,
962 Self::Skip { .. } => ActionKind::Skip,
963 Self::Nudge { .. } => ActionKind::Nudge,
964 Self::Done { .. } => ActionKind::Done,
965 Self::Hint { .. } => ActionKind::Hint,
966 Self::Rule { .. } => ActionKind::Rule,
967 Self::Review { .. } => ActionKind::Review,
968 Self::IssueComment { .. } => ActionKind::IssueComment,
969 Self::QuickChange { .. } => ActionKind::QuickChange,
970 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
971 Self::DropSetAside { .. } => ActionKind::DropSetAside,
972 Self::FixSetup => ActionKind::FixSetup,
973 Self::Feature => ActionKind::Feature,
974 Self::PullRequest => ActionKind::PullRequest,
975 Self::BugFix { .. } => ActionKind::BugFix,
976 Self::BugConfirm { .. } => ActionKind::BugConfirm,
977 Self::BugRun { .. } => ActionKind::BugRun,
978 Self::BugKeep { .. } => ActionKind::BugKeep,
979 Self::AllowAlways { .. } => ActionKind::AllowAlways,
980 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
981 Self::DecideBatch { .. } => ActionKind::DecideBatch,
982 Self::OracleTests { .. } => ActionKind::OracleTests,
983 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
984 Self::Comment { .. } => ActionKind::Comment,
985 Self::CommentsSend { .. } => ActionKind::CommentsSend,
986 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
987 Self::CodeNudge { .. } => ActionKind::CodeNudge,
988 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
989 Self::RuleScenario { .. } => ActionKind::RuleScenario,
990 Self::CheckStory { .. } => ActionKind::CheckStory,
991 Self::AcceptUntestable { .. } => ActionKind::AcceptUntestable,
992 Self::PresetAdd { .. } => ActionKind::PresetAdd,
993 Self::PlanThrough { .. } => ActionKind::PlanThrough,
994 Self::NewProject { .. } => ActionKind::NewProject,
995 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
996 Self::CloneRepository { .. } => ActionKind::CloneRepository,
997 Self::OpenSample { .. } => ActionKind::OpenSample,
998 Self::SampleChecks => ActionKind::SampleChecks,
999 Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
1000 Self::Explore { .. } => ActionKind::Explore,
1001 Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
1002 }
1003 }
1004}
1005
1006#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1010pub struct ActionId {
1011 pub kind: ActionKind,
1013 pub subject: Option<String>,
1016}
1017
1018#[must_use]
1021pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
1022 let Some(run) = run else {
1023 let words: Vec<&str> = command
1025 .split_whitespace()
1026 .skip_while(|w| *w == "speccoder")
1027 .take_while(|w| {
1028 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1029 && w.chars()
1030 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1031 })
1032 .collect();
1033 return ActionId {
1034 kind: ActionKind::Command,
1035 subject: (!words.is_empty()).then(|| words.join(" ")),
1036 };
1037 };
1038 let subject = match run {
1039 Runnable::Skill { name, .. } => Some(name.clone()),
1040 Runnable::Run { task, .. } => task.clone(),
1041 Runnable::Approve { task, .. }
1042 | Runnable::Reject { task, .. }
1043 | Runnable::Accept { task }
1044 | Runnable::Skip { task }
1045 | Runnable::Nudge { task }
1046 | Runnable::Done { task }
1047 | Runnable::Hint { task }
1048 | Runnable::Rule { task }
1049 | Runnable::KeepSetAside { task }
1050 | Runnable::DropSetAside { task }
1051 | Runnable::AllowAlways { task, .. }
1052 | Runnable::CodeNudge { task, .. }
1053 | Runnable::AcceptanceAt { task, .. }
1054 | Runnable::RuleScenario { task, .. }
1055 | Runnable::DeclinePromotion { task } => Some(task.clone()),
1056 Runnable::Explore { resume, .. } => resume.clone(),
1057 Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1058 Runnable::OracleTests { req, .. } => Some(req.clone()),
1059 Runnable::CheckStory { story } => Some(story.clone()),
1060 Runnable::AcceptUntestable { scenario } => Some(scenario.clone()),
1061 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1062 Runnable::Comment { id, .. } => id.clone(),
1063 Runnable::RevisionUndo { turn } => turn.clone(),
1064 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1065 Runnable::DecideBatch { run, .. } => Some(run.clone()),
1066 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1067 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1068 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1069 Runnable::BugFix { slug, issue, .. } => {
1070 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1071 }
1072 Runnable::Review { ids, verdict } => Some(match ids.first() {
1073 Some(id) => format!("{verdict}:{id}"),
1074 None => verdict.clone(),
1075 }),
1076 Runnable::PlanThrough { from, through } => Some(match from {
1077 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1078 PlanFrom::Continue => format!("continue:{}", through.as_str()),
1079 }),
1080 Runnable::NewProject { target, .. } => Some(target.clone()),
1081 Runnable::UndoNewProject { commit } => Some(commit.clone()),
1082 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1083 Runnable::Converge
1084 | Runnable::OpenSample { .. }
1085 | Runnable::SampleChecks
1086 | Runnable::Commit { .. }
1087 | Runnable::QuickChange { .. }
1088 | Runnable::FixSetup
1089 | Runnable::Feature
1090 | Runnable::PullRequest
1091 | Runnable::PresetAdd { .. }
1092 | Runnable::CommentsSend { .. } => None,
1093 };
1094 ActionId {
1095 kind: run.kind(),
1096 subject,
1097 }
1098}
1099
1100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1104#[serde(rename_all = "snake_case")]
1105pub enum SkillRole {
1106 Create,
1108 TowardTasks,
1110 Revise,
1112 Check,
1114 Other,
1116}
1117
1118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1123#[serde(rename_all = "snake_case")]
1124pub enum Via {
1125 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1127 Terminal,
1128 Web,
1130 Window,
1132 Acp,
1134 Phone,
1136 Ci,
1138 Mcp,
1140}
1141
1142impl Via {
1143 #[must_use]
1145 pub const fn as_str(self) -> &'static str {
1146 match self {
1147 Self::Terminal => "terminal",
1148 Self::Web => "web",
1149 Self::Window => "window",
1150 Self::Acp => "acp",
1151 Self::Phone => "phone",
1152 Self::Ci => "ci",
1153 Self::Mcp => "mcp",
1154 }
1155 }
1156
1157 #[must_use]
1160 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1161 if let Some(client) = recorded.strip_prefix("editor ") {
1162 return Some((Self::Acp, Some(client.to_owned())));
1163 }
1164 let via = match recorded {
1165 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1166 "web" => Self::Web,
1167 "window" => Self::Window,
1168 "acp" | "editor" => Self::Acp,
1169 "phone" => Self::Phone,
1170 "ci" => Self::Ci,
1171 "mcp" => Self::Mcp,
1172 _ => return None,
1173 };
1174 Some((via, None))
1175 }
1176}
1177
1178impl std::fmt::Display for Via {
1179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1180 f.write_str(self.as_str())
1181 }
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1186pub struct Action {
1187 pub group: String,
1189 pub label: String,
1191 pub why: String,
1193 pub command: String,
1195 pub run: Option<Runnable>,
1197 pub step: Option<crate::step::How>,
1200 pub blocked: Option<String>,
1202 pub input: Option<String>,
1204 pub confirm: Option<String>,
1207 pub id: ActionId,
1209 pub role: Option<SkillRole>,
1212}
1213
1214impl Action {
1215 #[must_use]
1217 pub fn new(
1218 group: &str,
1219 label: impl Into<String>,
1220 why: impl Into<String>,
1221 command: impl Into<String>,
1222 ) -> Self {
1223 let command = command.into();
1224 Self {
1225 group: group.to_owned(),
1226 label: label.into(),
1227 why: why.into(),
1228 id: identity(None, &command),
1229 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1230 command,
1231 run: None,
1232 blocked: None,
1233 input: None,
1234 confirm: None,
1235 role: None,
1236 }
1237 }
1238
1239 #[must_use]
1241 pub fn with_run(mut self, run: Runnable) -> Self {
1242 self.id = identity(Some(&run), &self.command);
1243 self.run = Some(run);
1244 self.step = None;
1245 self
1246 }
1247
1248 #[must_use]
1250 pub const fn with_role(mut self, role: SkillRole) -> Self {
1251 self.role = Some(role);
1252 self
1253 }
1254
1255 #[must_use]
1257 pub const fn runnable(&self) -> bool {
1258 self.run.is_some() && self.blocked.is_none()
1259 }
1260}
1261
1262#[must_use]
1264pub fn primary(actions: &[Action]) -> Option<&Action> {
1265 actions.iter().find(|a| a.group == "next" && a.runnable())
1266}
1267
1268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1270#[serde(rename_all = "snake_case")]
1271pub enum StepState {
1272 Done,
1274 Now,
1276 Todo,
1278 Skipped,
1281}
1282
1283#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1286pub struct JourneyStep {
1287 pub name: String,
1289 pub state: StepState,
1291}
1292
1293#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1295pub struct JourneyFacts {
1296 pub spec: bool,
1298 pub plan: bool,
1300 pub tasks: bool,
1302 pub uncommitted: bool,
1304 pub total: usize,
1306 pub finished: usize,
1308}
1309
1310#[must_use]
1314pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1315 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1316 let done = [
1317 ("spec", f.spec),
1318 ("plan", f.plan),
1319 ("tasks", f.tasks && f.total > 0),
1321 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1322 ("run", f.total > 0 && f.finished == f.total),
1323 ("review", false),
1324 ];
1325 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1326 let now = done
1327 .iter()
1328 .enumerate()
1329 .position(|(i, (_, d))| !d && !passed(i))
1330 .unwrap_or(done.len());
1331 done.iter()
1332 .enumerate()
1333 .map(|(i, (name, d))| JourneyStep {
1334 name: (*name).to_owned(),
1335 state: if *d && i < now {
1336 StepState::Done
1337 } else if passed(i) && i < now {
1338 StepState::Skipped
1339 } else if i == now {
1340 StepState::Now
1341 } else {
1342 StepState::Todo
1343 },
1344 })
1345 .collect()
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350 use super::*;
1351
1352 #[test]
1353 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1354 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1356 .unwrap_or_else(|e| panic!("{e}"));
1357 let mut named: Vec<String> = schema["oneOf"]
1358 .as_array()
1359 .into_iter()
1360 .flatten()
1361 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1362 .collect();
1363 named.sort();
1364 let mut listed: Vec<String> = ActionKind::ALL
1365 .iter()
1366 .map(|k| k.as_str().to_owned())
1367 .collect();
1368 listed.sort();
1369 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1370 for k in ActionKind::ALL {
1371 assert_eq!(
1372 serde_json::to_value(k).unwrap_or_default(),
1373 k.as_str(),
1374 "{k:?}"
1375 );
1376 if let Some(run) = k.example() {
1377 assert_eq!(run.kind(), *k);
1378 assert_eq!(identity(Some(&run), "").kind, *k);
1379 }
1380 }
1381 assert_eq!(
1382 Runnable::examples().len(),
1383 ActionKind::ALL.len() - 1,
1384 "every kind but `command` has a runnable"
1385 );
1386 }
1387
1388 #[test]
1389 fn identity_is_what_runs_never_how_it_is_worded() {
1390 let a = Action::new(
1391 "task",
1392 "accept T004",
1393 "tick it",
1394 "speccoder task accept T004",
1395 )
1396 .with_run(Runnable::Accept {
1397 task: "T004".to_owned(),
1398 });
1399 let mut b = a.clone();
1400 b.label = "x tick it anyway".to_owned();
1401 b.why = "x".to_owned();
1402 assert_eq!(a.id, b.id);
1403 assert_eq!(
1404 a.id,
1405 ActionId {
1406 kind: ActionKind::Accept,
1407 subject: Some("T004".to_owned())
1408 }
1409 );
1410 let id = |c: &str| identity(None, c).subject;
1411 assert_eq!(
1412 id("speccoder attest verify").as_deref(),
1413 Some("attest verify")
1414 );
1415 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1416 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1417 assert_eq!(
1418 id("speccoder spec specify \"…\"").as_deref(),
1419 Some("spec specify")
1420 );
1421 let review = |ids: &[&str], v: &str| {
1422 identity(
1423 Some(&Runnable::Review {
1424 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1425 verdict: v.to_owned(),
1426 }),
1427 "",
1428 )
1429 .subject
1430 };
1431 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1432 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1433 }
1434
1435 #[test]
1436 fn origins_read_back_from_every_value_ever_recorded() {
1437 for (old, via, client) in [
1438 ("cli", Via::Terminal, None),
1439 ("tui", Via::Terminal, None),
1440 ("dashboard", Via::Terminal, None),
1441 ("terminal", Via::Terminal, None),
1442 ("web", Via::Web, None),
1443 ("editor Zed", Via::Acp, Some("Zed")),
1444 ] {
1445 assert_eq!(
1446 Via::read(old),
1447 Some((via, client.map(str::to_owned))),
1448 "{old}"
1449 );
1450 }
1451 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1452 assert_eq!(parsed, Via::Terminal);
1453 assert_eq!(Via::Acp.as_str(), "acp");
1454 assert_eq!(Via::read("nobody"), None);
1455 }
1456
1457 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1458 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1459 }
1460
1461 #[test]
1462 fn the_journey_marks_what_is_done_and_what_is_now() {
1463 use StepState::{Done, Now, Todo};
1464 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1465 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1466 assert_eq!(
1467 states(JourneyFacts::default()),
1468 at([Now, Todo, Todo, Todo, Todo, Todo])
1469 );
1470 let planned = JourneyFacts {
1471 spec: true,
1472 plan: true,
1473 ..JourneyFacts::default()
1474 };
1475 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1476 let written = JourneyFacts {
1477 tasks: true,
1478 uncommitted: true,
1479 total: 3,
1480 ..planned
1481 };
1482 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1483 let running = JourneyFacts {
1484 uncommitted: false,
1485 finished: 1,
1486 ..written
1487 };
1488 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1489 let finished = JourneyFacts {
1490 finished: 3,
1491 ..running
1492 };
1493 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1494 let empty = JourneyFacts {
1496 tasks: true,
1497 ..planned
1498 };
1499 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1500 }
1501
1502 #[test]
1503 fn a_task_list_without_a_spec_skips_to_the_run() {
1504 use StepState::{Now, Skipped, Todo};
1505 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1506 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1507 let only_tasks = JourneyFacts {
1508 tasks: true,
1509 total: 4,
1510 finished: 1,
1511 ..JourneyFacts::default()
1512 };
1513 assert_eq!(
1514 states(only_tasks),
1515 at([
1516 Skipped,
1517 Skipped,
1518 StepState::Done,
1519 StepState::Done,
1520 Now,
1521 Todo
1522 ])
1523 );
1524 let planned = JourneyFacts {
1526 plan: true,
1527 ..JourneyFacts::default()
1528 };
1529 assert_eq!(
1530 states(planned),
1531 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1532 );
1533 }
1534}