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 #[serde(default, skip_serializing_if = "Option::is_none")]
29 cap: Option<String>,
30 },
31 Converge,
34 Commit {
36 paths: Vec<String>,
38 },
39 Approve {
42 task: String,
44 call: String,
46 digest: String,
48 },
49 Reject {
51 task: String,
53 call: String,
55 digest: String,
57 },
58 Accept {
60 task: String,
62 },
63 Skip {
65 task: String,
67 },
68 Nudge {
70 task: String,
72 },
73 Done {
76 task: String,
78 },
79 Hint {
81 task: String,
83 },
84 Rule {
86 task: String,
88 },
89 Review {
92 ids: Vec<String>,
94 verdict: String,
98 },
99 IssueComment {
101 issue: u64,
103 assign: bool,
105 },
106 QuickChange {
108 request: String,
110 checks: Vec<String>,
112 run: bool,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 asked: Option<String>,
117 },
118 KeepSetAside {
121 task: String,
123 },
124 DropSetAside {
126 task: String,
128 },
129 FixSetup,
132 Feature,
134 PullRequest,
136 BugFix {
139 symptom: String,
141 expected: Vec<String>,
143 unchanged: Vec<String>,
145 issue: Option<u64>,
147 tests: Vec<String>,
150 candidate: Option<String>,
152 slug: Option<String>,
154 run: bool,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 asked: Option<String>,
159 },
160 BugConfirm {
163 task: String,
165 digest: String,
167 reject: bool,
169 },
170 BugKeep {
173 task: String,
175 },
176 BugRun {
178 slug: Option<String>,
180 task: Option<String>,
182 },
183 AllowAlways {
186 task: String,
188 call: String,
190 },
191 WidenPolicy {
194 key: PolicyList,
196 prefix: Vec<String>,
198 digest: String,
200 },
201 DecideBatch {
203 run: String,
205 verdict: BatchVerdict,
207 calls: Vec<String>,
209 digest: String,
211 resume_asking: bool,
213 },
214 OracleTests {
217 req: String,
219 op: crate::oracle::OracleOp,
221 path: Option<String>,
223 scenario: Option<String>,
225 reading: Option<crate::oracle::Reading>,
227 },
228 ApproveAndRun {
231 mode: crate::oracle::RunMode,
233 grants: Vec<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
238 sheet: Option<String>,
239 },
240 Comment {
242 op: CommentOp,
244 anchor: Option<crate::oracle::CommentAnchor>,
246 id: Option<String>,
248 },
249 CommentsSend {
251 ids: Vec<String>,
254 },
255 RevisionUndo {
257 turn: Option<String>,
259 },
260 CodeNudge {
262 task: String,
264 comments: Vec<crate::oracle::CodeComment>,
266 },
267 AcceptanceAt {
271 feature: String,
273 story: String,
275 task: String,
277 revoke: bool,
279 },
280 CheckStory {
283 story: String,
285 },
286 RuleScenario {
290 task: String,
292 scenario: Option<String>,
294 },
295 AcceptUntestable {
299 scenario: String,
301 },
302 PresetAdd {
305 update: bool,
307 },
308 PlanThrough {
312 from: PlanFrom,
314 through: Through,
316 },
317 NewProject {
320 target: String,
322 choices: crate::new_project::NewChoices,
324 plan_digest: String,
326 },
327 UndoNewProject {
330 commit: String,
332 },
333 CloneRepository {
335 repo: String,
337 target: String,
339 },
340 OpenSample {
343 fresh: bool,
345 },
346 SampleChecks,
349 DeclinePromotion {
352 task: String,
354 },
355 Explore {
358 idea: String,
360 #[serde(default)]
362 skill: Option<String>,
363 #[serde(default)]
365 resume: Option<String>,
366 },
367 ExploreChoose {
370 session: String,
372 approach: usize,
374 #[serde(default)]
376 constraints: Vec<String>,
377 #[serde(default)]
379 questions: Vec<String>,
380 #[serde(default)]
382 flag: bool,
383 },
384}
385
386#[derive(
388 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
389)]
390#[serde(rename_all = "snake_case")]
391pub enum Through {
392 Spec,
394 Plan,
396 Tasks,
398 Analyze,
400}
401
402impl Through {
403 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
405
406 #[must_use]
408 pub const fn as_str(self) -> &'static str {
409 match self {
410 Self::Spec => "spec",
411 Self::Plan => "plan",
412 Self::Tasks => "tasks",
413 Self::Analyze => "analyze",
414 }
415 }
416
417 #[must_use]
419 pub fn parse(s: &str) -> Option<Self> {
420 Self::ALL.into_iter().find(|t| t.as_str() == s)
421 }
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
427#[serde(tag = "kind", rename_all = "snake_case")]
428pub enum FeatureSource {
429 Description,
431 Issue {
433 number: u64,
435 },
436 Promotion {
439 task: String,
441 #[serde(default)]
443 conversations: Vec<String>,
444 },
445 Explore {
447 session: String,
449 digest: String,
451 },
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
456#[serde(tag = "kind", rename_all = "snake_case")]
457pub enum PlanFrom {
458 Create {
460 description: String,
462 source: FeatureSource,
464 },
465 Continue,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
471#[serde(rename_all = "snake_case")]
472pub enum CommentOp {
473 Add,
475 Delete,
477 Keep,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
483#[serde(rename_all = "snake_case")]
484pub enum PolicyList {
485 CommandsPrefix,
487 NetworkPrefix,
489}
490
491impl PolicyList {
492 #[must_use]
494 pub const fn as_str(self) -> &'static str {
495 match self {
496 Self::CommandsPrefix => "commands_prefix",
497 Self::NetworkPrefix => "network_prefix",
498 }
499 }
500
501 #[must_use]
503 pub const fn key(self) -> &'static str {
504 match self {
505 Self::CommandsPrefix => "commands.allow_prefix",
506 Self::NetworkPrefix => "sandbox.network_allow_prefix",
507 }
508 }
509
510 #[must_use]
512 pub fn of_key(key: &str) -> Option<Self> {
513 match key {
514 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
515 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
516 _ => None,
517 }
518 }
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
523#[serde(rename_all = "snake_case")]
524pub enum BatchVerdict {
525 Approve,
527 Reject,
529}
530
531#[derive(
537 Debug,
538 Clone,
539 Copy,
540 PartialEq,
541 Eq,
542 Hash,
543 PartialOrd,
544 Ord,
545 Serialize,
546 Deserialize,
547 schemars::JsonSchema,
548)]
549#[serde(rename_all = "snake_case")]
550pub enum ActionKind {
551 Skill,
553 RunTask,
555 RunFeature,
557 Converge,
559 Commit,
561 Approve,
563 Reject,
565 Accept,
567 Skip,
569 Nudge,
571 Done,
573 Hint,
575 Rule,
577 Review,
579 IssueComment,
581 QuickChange,
583 KeepSetAside,
585 DropSetAside,
587 FixSetup,
589 Feature,
591 PullRequest,
593 BugFix,
595 BugConfirm,
597 BugRun,
599 BugKeep,
601 AllowAlways,
603 WidenPolicy,
605 DecideBatch,
607 OracleTests,
609 ApproveAndRun,
611 Comment,
613 CommentsSend,
615 RevisionUndo,
617 CodeNudge,
619 AcceptanceAt,
621 RuleScenario,
623 CheckStory,
625 AcceptUntestable,
627 PresetAdd,
629 PlanThrough,
631 NewProject,
633 UndoNewProject,
635 CloneRepository,
637 OpenSample,
639 SampleChecks,
641 DeclinePromotion,
643 Explore,
645 ExploreChoose,
647 Command,
649}
650
651impl ActionKind {
652 pub const ALL: &'static [Self] = &[
654 Self::Skill,
655 Self::RunTask,
656 Self::RunFeature,
657 Self::Converge,
658 Self::Commit,
659 Self::Approve,
660 Self::Reject,
661 Self::Accept,
662 Self::Skip,
663 Self::Nudge,
664 Self::Done,
665 Self::Hint,
666 Self::Rule,
667 Self::Review,
668 Self::IssueComment,
669 Self::QuickChange,
670 Self::KeepSetAside,
671 Self::DropSetAside,
672 Self::FixSetup,
673 Self::Feature,
674 Self::PullRequest,
675 Self::BugFix,
676 Self::BugConfirm,
677 Self::BugRun,
678 Self::BugKeep,
679 Self::AllowAlways,
680 Self::WidenPolicy,
681 Self::DecideBatch,
682 Self::OracleTests,
683 Self::ApproveAndRun,
684 Self::Comment,
685 Self::CommentsSend,
686 Self::RevisionUndo,
687 Self::CodeNudge,
688 Self::AcceptanceAt,
689 Self::RuleScenario,
690 Self::CheckStory,
691 Self::AcceptUntestable,
692 Self::PresetAdd,
693 Self::PlanThrough,
694 Self::NewProject,
695 Self::UndoNewProject,
696 Self::CloneRepository,
697 Self::OpenSample,
698 Self::SampleChecks,
699 Self::DeclinePromotion,
700 Self::Explore,
701 Self::ExploreChoose,
702 Self::Command,
703 ];
704
705 #[must_use]
707 pub const fn as_str(self) -> &'static str {
708 match self {
709 Self::Skill => "skill",
710 Self::RunTask => "run_task",
711 Self::RunFeature => "run_feature",
712 Self::Converge => "converge",
713 Self::Commit => "commit",
714 Self::Approve => "approve",
715 Self::Reject => "reject",
716 Self::Accept => "accept",
717 Self::Skip => "skip",
718 Self::Nudge => "nudge",
719 Self::Done => "done",
720 Self::Hint => "hint",
721 Self::Rule => "rule",
722 Self::Review => "review",
723 Self::IssueComment => "issue_comment",
724 Self::QuickChange => "quick_change",
725 Self::KeepSetAside => "keep_set_aside",
726 Self::DropSetAside => "drop_set_aside",
727 Self::FixSetup => "fix_setup",
728 Self::Feature => "feature",
729 Self::PullRequest => "pull_request",
730 Self::BugFix => "bug_fix",
731 Self::BugConfirm => "bug_confirm",
732 Self::BugRun => "bug_run",
733 Self::BugKeep => "bug_keep",
734 Self::AllowAlways => "allow_always",
735 Self::WidenPolicy => "widen_policy",
736 Self::DecideBatch => "decide_batch",
737 Self::OracleTests => "oracle_tests",
738 Self::ApproveAndRun => "approve_and_run",
739 Self::Comment => "comment",
740 Self::CommentsSend => "comments_send",
741 Self::RevisionUndo => "revision_undo",
742 Self::CodeNudge => "code_nudge",
743 Self::AcceptanceAt => "acceptance_at",
744 Self::RuleScenario => "rule_scenario",
745 Self::CheckStory => "check_story",
746 Self::AcceptUntestable => "accept_untestable",
747 Self::PresetAdd => "preset_add",
748 Self::PlanThrough => "plan_through",
749 Self::NewProject => "new_project",
750 Self::UndoNewProject => "undo_new_project",
751 Self::CloneRepository => "clone_repository",
752 Self::OpenSample => "open_sample",
753 Self::SampleChecks => "sample_checks",
754 Self::DeclinePromotion => "decline_promotion",
755 Self::Explore => "explore",
756 Self::ExploreChoose => "explore_choose",
757 Self::Command => "command",
758 }
759 }
760
761 #[must_use]
764 pub fn example(self) -> Option<Runnable> {
765 let task = || "T001".to_owned();
766 Some(match self {
767 Self::Skill => Runnable::Skill {
768 name: "plan".to_owned(),
769 arguments: String::new(),
770 },
771 Self::RunTask => Runnable::Run {
772 task: Some(task()),
773 agent: None,
774 cap: None,
775 },
776 Self::RunFeature => Runnable::Run {
777 task: None,
778 agent: None,
779 cap: None,
780 },
781 Self::Converge => Runnable::Converge,
782 Self::Commit => Runnable::Commit {
783 paths: vec!["plan.md".to_owned()],
784 },
785 Self::Approve => Runnable::Approve {
786 task: task(),
787 call: "0f3c".to_owned(),
788 digest: "d1".to_owned(),
789 },
790 Self::Reject => Runnable::Reject {
791 task: task(),
792 call: "0f3c".to_owned(),
793 digest: "d1".to_owned(),
794 },
795 Self::Accept => Runnable::Accept { task: task() },
796 Self::Skip => Runnable::Skip { task: task() },
797 Self::Nudge => Runnable::Nudge { task: task() },
798 Self::Done => Runnable::Done { task: task() },
799 Self::Hint => Runnable::Hint { task: task() },
800 Self::Rule => Runnable::Rule { task: task() },
801 Self::Review => Runnable::Review {
802 ids: vec!["FR-001".to_owned()],
803 verdict: "approve".to_owned(),
804 },
805 Self::IssueComment => Runnable::IssueComment {
806 issue: 42,
807 assign: false,
808 },
809 Self::QuickChange => Runnable::QuickChange {
810 request: "a change".to_owned(),
811 checks: Vec::new(),
812 run: false,
813 asked: None,
814 },
815 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
816 Self::DropSetAside => Runnable::DropSetAside { task: task() },
817 Self::FixSetup => Runnable::FixSetup,
818 Self::Feature => Runnable::Feature,
819 Self::PullRequest => Runnable::PullRequest,
820 Self::BugFix => Runnable::BugFix {
821 symptom: "saving an empty title crashes".to_owned(),
822 expected: vec!["an empty title is refused".to_owned()],
823 unchanged: Vec::new(),
824 issue: None,
825 tests: Vec::new(),
826 candidate: None,
827 slug: None,
828 run: false,
829 asked: None,
830 },
831 Self::BugConfirm => Runnable::BugConfirm {
832 task: "007-bugfixes/T001".to_owned(),
833 digest: "sha256:0".to_owned(),
834 reject: false,
835 },
836 Self::BugRun => Runnable::BugRun {
837 slug: None,
838 task: Some("007-bugfixes/T001".to_owned()),
839 },
840 Self::BugKeep => Runnable::BugKeep {
841 task: "007-bugfixes/T001".to_owned(),
842 },
843 Self::AllowAlways => Runnable::AllowAlways {
844 task: task(),
845 call: "0f3c".to_owned(),
846 },
847 Self::WidenPolicy => Runnable::WidenPolicy {
848 key: PolicyList::CommandsPrefix,
849 prefix: vec!["npm".to_owned(), "run".to_owned()],
850 digest: "d1".to_owned(),
851 },
852 Self::DecideBatch => Runnable::DecideBatch {
853 run: "run-1-0a0b0c0d".to_owned(),
854 verdict: BatchVerdict::Approve,
855 calls: vec!["0f3c".to_owned()],
856 digest: "d1".to_owned(),
857 resume_asking: false,
858 },
859 Self::OracleTests => Runnable::OracleTests {
860 req: "US1".to_owned(),
861 op: crate::oracle::OracleOp::Write,
862 path: None,
863 scenario: None,
864 reading: None,
865 },
866 Self::ApproveAndRun => Runnable::ApproveAndRun {
867 mode: crate::oracle::RunMode::Ask,
868 grants: Vec::new(),
869 sheet: None,
870 },
871 Self::Comment => Runnable::Comment {
872 op: CommentOp::Delete,
873 anchor: None,
874 id: Some("c1".to_owned()),
875 },
876 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
877 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
878 Self::CodeNudge => Runnable::CodeNudge {
879 task: task(),
880 comments: vec![crate::oracle::CodeComment {
881 path: "src/lib.rs".to_owned(),
882 line: 1,
883 quote: "pub fn ok() {}".to_owned(),
884 text: "name it".to_owned(),
885 }],
886 },
887 Self::AcceptanceAt => Runnable::AcceptanceAt {
888 feature: "001-greet-function".to_owned(),
889 story: "US1".to_owned(),
890 task: "T003".to_owned(),
891 revoke: false,
892 },
893 Self::CheckStory => Runnable::CheckStory {
894 story: "US3".to_owned(),
895 },
896 Self::AcceptUntestable => Runnable::AcceptUntestable {
897 scenario: "US2-2".to_owned(),
898 },
899 Self::RuleScenario => Runnable::RuleScenario {
900 task: "T021".to_owned(),
901 scenario: Some("US3-2".to_owned()),
902 },
903 Self::PresetAdd => Runnable::PresetAdd { update: false },
904 Self::PlanThrough => Runnable::PlanThrough {
905 from: PlanFrom::Create {
906 description: "Users can export a report as CSV".to_owned(),
907 source: FeatureSource::Description,
908 },
909 through: Through::Tasks,
910 },
911 Self::NewProject => Runnable::NewProject {
912 target: "/tmp/md2csv".to_owned(),
913 choices: crate::new_project::NewChoices {
914 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
915 ..crate::new_project::NewChoices::default()
916 },
917 plan_digest: "sha256:0".to_owned(),
918 },
919 Self::UndoNewProject => Runnable::UndoNewProject {
920 commit: "1a2b3c4".to_owned(),
921 },
922 Self::CloneRepository => Runnable::CloneRepository {
923 repo: "octo/hello".to_owned(),
924 target: "/tmp/hello".to_owned(),
925 },
926 Self::OpenSample => Runnable::OpenSample { fresh: false },
927 Self::SampleChecks => Runnable::SampleChecks,
928 Self::DeclinePromotion => Runnable::DeclinePromotion {
929 task: "004-quick-changes/T002".to_owned(),
930 },
931 Self::Explore => Runnable::Explore {
932 idea: "export reports as CSV".to_owned(),
933 skill: None,
934 resume: None,
935 },
936 Self::ExploreChoose => Runnable::ExploreChoose {
937 session: "ex-1a2b3c4d5e6f".to_owned(),
938 approach: 2,
939 constraints: vec!["no new dependency".to_owned()],
940 questions: Vec::new(),
941 flag: false,
942 },
943 Self::Command => return None,
944 })
945 }
946}
947
948impl Runnable {
949 #[must_use]
951 pub fn examples() -> Vec<Self> {
952 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
953 }
954
955 #[must_use]
957 pub const fn kind(&self) -> ActionKind {
958 match self {
959 Self::Skill { .. } => ActionKind::Skill,
960 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
961 Self::Run { task: None, .. } => ActionKind::RunFeature,
962 Self::Converge => ActionKind::Converge,
963 Self::Commit { .. } => ActionKind::Commit,
964 Self::Approve { .. } => ActionKind::Approve,
965 Self::Reject { .. } => ActionKind::Reject,
966 Self::Accept { .. } => ActionKind::Accept,
967 Self::Skip { .. } => ActionKind::Skip,
968 Self::Nudge { .. } => ActionKind::Nudge,
969 Self::Done { .. } => ActionKind::Done,
970 Self::Hint { .. } => ActionKind::Hint,
971 Self::Rule { .. } => ActionKind::Rule,
972 Self::Review { .. } => ActionKind::Review,
973 Self::IssueComment { .. } => ActionKind::IssueComment,
974 Self::QuickChange { .. } => ActionKind::QuickChange,
975 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
976 Self::DropSetAside { .. } => ActionKind::DropSetAside,
977 Self::FixSetup => ActionKind::FixSetup,
978 Self::Feature => ActionKind::Feature,
979 Self::PullRequest => ActionKind::PullRequest,
980 Self::BugFix { .. } => ActionKind::BugFix,
981 Self::BugConfirm { .. } => ActionKind::BugConfirm,
982 Self::BugRun { .. } => ActionKind::BugRun,
983 Self::BugKeep { .. } => ActionKind::BugKeep,
984 Self::AllowAlways { .. } => ActionKind::AllowAlways,
985 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
986 Self::DecideBatch { .. } => ActionKind::DecideBatch,
987 Self::OracleTests { .. } => ActionKind::OracleTests,
988 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
989 Self::Comment { .. } => ActionKind::Comment,
990 Self::CommentsSend { .. } => ActionKind::CommentsSend,
991 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
992 Self::CodeNudge { .. } => ActionKind::CodeNudge,
993 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
994 Self::RuleScenario { .. } => ActionKind::RuleScenario,
995 Self::CheckStory { .. } => ActionKind::CheckStory,
996 Self::AcceptUntestable { .. } => ActionKind::AcceptUntestable,
997 Self::PresetAdd { .. } => ActionKind::PresetAdd,
998 Self::PlanThrough { .. } => ActionKind::PlanThrough,
999 Self::NewProject { .. } => ActionKind::NewProject,
1000 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
1001 Self::CloneRepository { .. } => ActionKind::CloneRepository,
1002 Self::OpenSample { .. } => ActionKind::OpenSample,
1003 Self::SampleChecks => ActionKind::SampleChecks,
1004 Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
1005 Self::Explore { .. } => ActionKind::Explore,
1006 Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
1007 }
1008 }
1009}
1010
1011#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1015pub struct ActionId {
1016 pub kind: ActionKind,
1018 pub subject: Option<String>,
1021}
1022
1023#[must_use]
1026pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
1027 let Some(run) = run else {
1028 let words: Vec<&str> = command
1030 .split_whitespace()
1031 .skip_while(|w| *w == "speccoder")
1032 .take_while(|w| {
1033 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1034 && w.chars()
1035 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1036 })
1037 .collect();
1038 return ActionId {
1039 kind: ActionKind::Command,
1040 subject: (!words.is_empty()).then(|| words.join(" ")),
1041 };
1042 };
1043 let subject = match run {
1044 Runnable::Skill { name, .. } => Some(name.clone()),
1045 Runnable::Run { task, .. } => task.clone(),
1046 Runnable::Approve { task, .. }
1047 | Runnable::Reject { task, .. }
1048 | Runnable::Accept { task }
1049 | Runnable::Skip { task }
1050 | Runnable::Nudge { task }
1051 | Runnable::Done { task }
1052 | Runnable::Hint { task }
1053 | Runnable::Rule { task }
1054 | Runnable::KeepSetAside { task }
1055 | Runnable::DropSetAside { task }
1056 | Runnable::AllowAlways { task, .. }
1057 | Runnable::CodeNudge { task, .. }
1058 | Runnable::AcceptanceAt { task, .. }
1059 | Runnable::RuleScenario { task, .. }
1060 | Runnable::DeclinePromotion { task } => Some(task.clone()),
1061 Runnable::Explore { resume, .. } => resume.clone(),
1062 Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1063 Runnable::OracleTests { req, .. } => Some(req.clone()),
1064 Runnable::CheckStory { story } => Some(story.clone()),
1065 Runnable::AcceptUntestable { scenario } => Some(scenario.clone()),
1066 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1067 Runnable::Comment { id, .. } => id.clone(),
1068 Runnable::RevisionUndo { turn } => turn.clone(),
1069 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1070 Runnable::DecideBatch { run, .. } => Some(run.clone()),
1071 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1072 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1073 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1074 Runnable::BugFix { slug, issue, .. } => {
1075 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1076 }
1077 Runnable::Review { ids, verdict } => Some(match ids.first() {
1078 Some(id) => format!("{verdict}:{id}"),
1079 None => verdict.clone(),
1080 }),
1081 Runnable::PlanThrough { from, through } => Some(match from {
1082 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1083 PlanFrom::Continue => format!("continue:{}", through.as_str()),
1084 }),
1085 Runnable::NewProject { target, .. } => Some(target.clone()),
1086 Runnable::UndoNewProject { commit } => Some(commit.clone()),
1087 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1088 Runnable::Converge
1089 | Runnable::OpenSample { .. }
1090 | Runnable::SampleChecks
1091 | Runnable::Commit { .. }
1092 | Runnable::QuickChange { .. }
1093 | Runnable::FixSetup
1094 | Runnable::Feature
1095 | Runnable::PullRequest
1096 | Runnable::PresetAdd { .. }
1097 | Runnable::CommentsSend { .. } => None,
1098 };
1099 ActionId {
1100 kind: run.kind(),
1101 subject,
1102 }
1103}
1104
1105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1109#[serde(rename_all = "snake_case")]
1110pub enum SkillRole {
1111 Create,
1113 TowardTasks,
1115 Revise,
1117 Check,
1119 Other,
1121}
1122
1123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1128#[serde(rename_all = "snake_case")]
1129pub enum Via {
1130 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1132 Terminal,
1133 Web,
1135 Window,
1137 Acp,
1139 Phone,
1141 Ci,
1143 Mcp,
1145}
1146
1147impl Via {
1148 #[must_use]
1150 pub const fn as_str(self) -> &'static str {
1151 match self {
1152 Self::Terminal => "terminal",
1153 Self::Web => "web",
1154 Self::Window => "window",
1155 Self::Acp => "acp",
1156 Self::Phone => "phone",
1157 Self::Ci => "ci",
1158 Self::Mcp => "mcp",
1159 }
1160 }
1161
1162 #[must_use]
1165 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1166 if let Some(client) = recorded.strip_prefix("editor ") {
1167 return Some((Self::Acp, Some(client.to_owned())));
1168 }
1169 let via = match recorded {
1170 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1171 "web" => Self::Web,
1172 "window" => Self::Window,
1173 "acp" | "editor" => Self::Acp,
1174 "phone" => Self::Phone,
1175 "ci" => Self::Ci,
1176 "mcp" => Self::Mcp,
1177 _ => return None,
1178 };
1179 Some((via, None))
1180 }
1181}
1182
1183impl std::fmt::Display for Via {
1184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1185 f.write_str(self.as_str())
1186 }
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1191pub struct Action {
1192 pub group: String,
1194 pub label: String,
1196 pub why: String,
1198 pub command: String,
1200 pub run: Option<Runnable>,
1202 pub step: Option<crate::step::How>,
1205 pub blocked: Option<String>,
1207 pub input: Option<String>,
1209 pub confirm: Option<String>,
1212 pub id: ActionId,
1214 pub role: Option<SkillRole>,
1217}
1218
1219impl Action {
1220 #[must_use]
1222 pub fn new(
1223 group: &str,
1224 label: impl Into<String>,
1225 why: impl Into<String>,
1226 command: impl Into<String>,
1227 ) -> Self {
1228 let command = command.into();
1229 Self {
1230 group: group.to_owned(),
1231 label: label.into(),
1232 why: why.into(),
1233 id: identity(None, &command),
1234 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1235 command,
1236 run: None,
1237 blocked: None,
1238 input: None,
1239 confirm: None,
1240 role: None,
1241 }
1242 }
1243
1244 #[must_use]
1246 pub fn with_run(mut self, run: Runnable) -> Self {
1247 self.id = identity(Some(&run), &self.command);
1248 self.run = Some(run);
1249 self.step = None;
1250 self
1251 }
1252
1253 #[must_use]
1255 pub const fn with_role(mut self, role: SkillRole) -> Self {
1256 self.role = Some(role);
1257 self
1258 }
1259
1260 #[must_use]
1262 pub const fn runnable(&self) -> bool {
1263 self.run.is_some() && self.blocked.is_none()
1264 }
1265}
1266
1267#[must_use]
1269pub fn primary(actions: &[Action]) -> Option<&Action> {
1270 actions.iter().find(|a| a.group == "next" && a.runnable())
1271}
1272
1273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1275#[serde(rename_all = "snake_case")]
1276pub enum StepState {
1277 Done,
1279 Now,
1281 Todo,
1283 Skipped,
1286}
1287
1288#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1291pub struct JourneyStep {
1292 pub name: String,
1294 pub state: StepState,
1296}
1297
1298#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1300pub struct JourneyFacts {
1301 pub spec: bool,
1303 pub plan: bool,
1305 pub tasks: bool,
1307 pub uncommitted: bool,
1309 pub total: usize,
1311 pub finished: usize,
1313}
1314
1315#[must_use]
1319pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1320 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1321 let done = [
1322 ("spec", f.spec),
1323 ("plan", f.plan),
1324 ("tasks", f.tasks && f.total > 0),
1326 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1327 ("run", f.total > 0 && f.finished == f.total),
1328 ("review", false),
1329 ];
1330 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1331 let now = done
1332 .iter()
1333 .enumerate()
1334 .position(|(i, (_, d))| !d && !passed(i))
1335 .unwrap_or(done.len());
1336 done.iter()
1337 .enumerate()
1338 .map(|(i, (name, d))| JourneyStep {
1339 name: (*name).to_owned(),
1340 state: if *d && i < now {
1341 StepState::Done
1342 } else if passed(i) && i < now {
1343 StepState::Skipped
1344 } else if i == now {
1345 StepState::Now
1346 } else {
1347 StepState::Todo
1348 },
1349 })
1350 .collect()
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355 use super::*;
1356
1357 #[test]
1358 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1359 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1361 .unwrap_or_else(|e| panic!("{e}"));
1362 let mut named: Vec<String> = schema["oneOf"]
1363 .as_array()
1364 .into_iter()
1365 .flatten()
1366 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1367 .collect();
1368 named.sort();
1369 let mut listed: Vec<String> = ActionKind::ALL
1370 .iter()
1371 .map(|k| k.as_str().to_owned())
1372 .collect();
1373 listed.sort();
1374 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1375 for k in ActionKind::ALL {
1376 assert_eq!(
1377 serde_json::to_value(k).unwrap_or_default(),
1378 k.as_str(),
1379 "{k:?}"
1380 );
1381 if let Some(run) = k.example() {
1382 assert_eq!(run.kind(), *k);
1383 assert_eq!(identity(Some(&run), "").kind, *k);
1384 }
1385 }
1386 assert_eq!(
1387 Runnable::examples().len(),
1388 ActionKind::ALL.len() - 1,
1389 "every kind but `command` has a runnable"
1390 );
1391 }
1392
1393 #[test]
1394 fn identity_is_what_runs_never_how_it_is_worded() {
1395 let a = Action::new(
1396 "task",
1397 "accept T004",
1398 "tick it",
1399 "speccoder task accept T004",
1400 )
1401 .with_run(Runnable::Accept {
1402 task: "T004".to_owned(),
1403 });
1404 let mut b = a.clone();
1405 b.label = "x tick it anyway".to_owned();
1406 b.why = "x".to_owned();
1407 assert_eq!(a.id, b.id);
1408 assert_eq!(
1409 a.id,
1410 ActionId {
1411 kind: ActionKind::Accept,
1412 subject: Some("T004".to_owned())
1413 }
1414 );
1415 let id = |c: &str| identity(None, c).subject;
1416 assert_eq!(
1417 id("speccoder attest verify").as_deref(),
1418 Some("attest verify")
1419 );
1420 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1421 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1422 assert_eq!(
1423 id("speccoder spec specify \"…\"").as_deref(),
1424 Some("spec specify")
1425 );
1426 let review = |ids: &[&str], v: &str| {
1427 identity(
1428 Some(&Runnable::Review {
1429 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1430 verdict: v.to_owned(),
1431 }),
1432 "",
1433 )
1434 .subject
1435 };
1436 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1437 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1438 }
1439
1440 #[test]
1441 fn origins_read_back_from_every_value_ever_recorded() {
1442 for (old, via, client) in [
1443 ("cli", Via::Terminal, None),
1444 ("tui", Via::Terminal, None),
1445 ("dashboard", Via::Terminal, None),
1446 ("terminal", Via::Terminal, None),
1447 ("web", Via::Web, None),
1448 ("editor Zed", Via::Acp, Some("Zed")),
1449 ] {
1450 assert_eq!(
1451 Via::read(old),
1452 Some((via, client.map(str::to_owned))),
1453 "{old}"
1454 );
1455 }
1456 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1457 assert_eq!(parsed, Via::Terminal);
1458 assert_eq!(Via::Acp.as_str(), "acp");
1459 assert_eq!(Via::read("nobody"), None);
1460 }
1461
1462 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1463 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1464 }
1465
1466 #[test]
1467 fn the_journey_marks_what_is_done_and_what_is_now() {
1468 use StepState::{Done, Now, Todo};
1469 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1470 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1471 assert_eq!(
1472 states(JourneyFacts::default()),
1473 at([Now, Todo, Todo, Todo, Todo, Todo])
1474 );
1475 let planned = JourneyFacts {
1476 spec: true,
1477 plan: true,
1478 ..JourneyFacts::default()
1479 };
1480 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1481 let written = JourneyFacts {
1482 tasks: true,
1483 uncommitted: true,
1484 total: 3,
1485 ..planned
1486 };
1487 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1488 let running = JourneyFacts {
1489 uncommitted: false,
1490 finished: 1,
1491 ..written
1492 };
1493 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1494 let finished = JourneyFacts {
1495 finished: 3,
1496 ..running
1497 };
1498 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1499 let empty = JourneyFacts {
1501 tasks: true,
1502 ..planned
1503 };
1504 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1505 }
1506
1507 #[test]
1508 fn a_task_list_without_a_spec_skips_to_the_run() {
1509 use StepState::{Now, Skipped, Todo};
1510 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1511 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1512 let only_tasks = JourneyFacts {
1513 tasks: true,
1514 total: 4,
1515 finished: 1,
1516 ..JourneyFacts::default()
1517 };
1518 assert_eq!(
1519 states(only_tasks),
1520 at([
1521 Skipped,
1522 Skipped,
1523 StepState::Done,
1524 StepState::Done,
1525 Now,
1526 Todo
1527 ])
1528 );
1529 let planned = JourneyFacts {
1531 plan: true,
1532 ..JourneyFacts::default()
1533 };
1534 assert_eq!(
1535 states(planned),
1536 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1537 );
1538 }
1539}