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 HookTrust {
120 name: String,
122 digest: String,
124 },
125 ExtensionTrust {
127 name: String,
129 digest: String,
131 },
132 ViewRun {
134 extension: String,
136 view: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 feature: Option<String>,
141 },
142 HookTest {
144 name: String,
146 },
147 KeepSetAside {
150 task: String,
152 },
153 DropSetAside {
155 task: String,
157 },
158 FixSetup,
161 Feature,
163 PullRequest,
165 BugFix {
168 symptom: String,
170 expected: Vec<String>,
172 unchanged: Vec<String>,
174 issue: Option<u64>,
176 tests: Vec<String>,
179 candidate: Option<String>,
181 slug: Option<String>,
183 run: bool,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 asked: Option<String>,
188 },
189 BugConfirm {
192 task: String,
194 digest: String,
196 reject: bool,
198 },
199 BugKeep {
202 task: String,
204 },
205 BugRun {
207 slug: Option<String>,
209 task: Option<String>,
211 },
212 AllowAlways {
215 task: String,
217 call: String,
219 },
220 WidenPolicy {
223 key: PolicyList,
225 prefix: Vec<String>,
227 digest: String,
229 },
230 DecideBatch {
232 run: String,
234 verdict: BatchVerdict,
236 calls: Vec<String>,
238 digest: String,
240 resume_asking: bool,
242 },
243 OracleTests {
246 req: String,
248 op: crate::oracle::OracleOp,
250 path: Option<String>,
252 scenario: Option<String>,
254 reading: Option<crate::oracle::Reading>,
256 },
257 ApproveAndRun {
260 mode: crate::oracle::RunMode,
262 grants: Vec<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
267 sheet: Option<String>,
268 },
269 Comment {
271 op: CommentOp,
273 anchor: Option<crate::oracle::CommentAnchor>,
275 id: Option<String>,
277 },
278 CommentsSend {
280 ids: Vec<String>,
283 },
284 RevisionUndo {
286 turn: Option<String>,
288 },
289 CodeNudge {
291 task: String,
293 comments: Vec<crate::oracle::CodeComment>,
295 },
296 AcceptanceAt {
300 feature: String,
302 story: String,
304 task: String,
306 revoke: bool,
308 },
309 CheckStory {
312 story: String,
314 },
315 RuleScenario {
319 task: String,
321 scenario: Option<String>,
323 },
324 AcceptUntestable {
328 scenario: String,
330 },
331 PresetAdd {
334 update: bool,
336 },
337 PlanThrough {
341 from: PlanFrom,
343 through: Through,
345 },
346 NewProject {
349 target: String,
351 choices: crate::new_project::NewChoices,
353 plan_digest: String,
355 },
356 UndoNewProject {
359 commit: String,
361 },
362 CloneRepository {
364 repo: String,
366 target: String,
368 },
369 OpenSample {
372 fresh: bool,
374 },
375 SampleChecks,
378 DeclinePromotion {
381 task: String,
383 },
384 Explore {
387 idea: String,
389 #[serde(default)]
391 skill: Option<String>,
392 #[serde(default)]
394 resume: Option<String>,
395 },
396 ExploreChoose {
399 session: String,
401 approach: usize,
403 #[serde(default)]
405 constraints: Vec<String>,
406 #[serde(default)]
408 questions: Vec<String>,
409 #[serde(default)]
411 flag: bool,
412 },
413}
414
415#[derive(
417 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
418)]
419#[serde(rename_all = "snake_case")]
420pub enum Through {
421 Spec,
423 Plan,
425 Tasks,
427 Analyze,
429}
430
431impl Through {
432 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
434
435 #[must_use]
437 pub const fn as_str(self) -> &'static str {
438 match self {
439 Self::Spec => "spec",
440 Self::Plan => "plan",
441 Self::Tasks => "tasks",
442 Self::Analyze => "analyze",
443 }
444 }
445
446 #[must_use]
448 pub fn parse(s: &str) -> Option<Self> {
449 Self::ALL.into_iter().find(|t| t.as_str() == s)
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
456#[serde(tag = "kind", rename_all = "snake_case")]
457pub enum FeatureSource {
458 Description,
460 Issue {
462 number: u64,
464 },
465 Promotion {
468 task: String,
470 #[serde(default)]
472 conversations: Vec<String>,
473 },
474 Explore {
476 session: String,
478 digest: String,
480 },
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
485#[serde(tag = "kind", rename_all = "snake_case")]
486pub enum PlanFrom {
487 Create {
489 description: String,
491 source: FeatureSource,
493 },
494 Continue,
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
500#[serde(rename_all = "snake_case")]
501pub enum CommentOp {
502 Add,
504 Delete,
506 Keep,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
512#[serde(rename_all = "snake_case")]
513pub enum PolicyList {
514 CommandsPrefix,
516 NetworkPrefix,
518}
519
520impl PolicyList {
521 #[must_use]
523 pub const fn as_str(self) -> &'static str {
524 match self {
525 Self::CommandsPrefix => "commands_prefix",
526 Self::NetworkPrefix => "network_prefix",
527 }
528 }
529
530 #[must_use]
532 pub const fn key(self) -> &'static str {
533 match self {
534 Self::CommandsPrefix => "commands.allow_prefix",
535 Self::NetworkPrefix => "sandbox.network_allow_prefix",
536 }
537 }
538
539 #[must_use]
541 pub fn of_key(key: &str) -> Option<Self> {
542 match key {
543 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
544 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
545 _ => None,
546 }
547 }
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
552#[serde(rename_all = "snake_case")]
553pub enum BatchVerdict {
554 Approve,
556 Reject,
558}
559
560#[derive(
566 Debug,
567 Clone,
568 Copy,
569 PartialEq,
570 Eq,
571 Hash,
572 PartialOrd,
573 Ord,
574 Serialize,
575 Deserialize,
576 schemars::JsonSchema,
577)]
578#[serde(rename_all = "snake_case")]
579pub enum ActionKind {
580 Skill,
582 RunTask,
584 RunFeature,
586 Converge,
588 Commit,
590 Approve,
592 Reject,
594 Accept,
596 Skip,
598 Nudge,
600 Done,
602 Hint,
604 Rule,
606 Review,
608 IssueComment,
610 QuickChange,
612 HookTrust,
614 ExtensionTrust,
616 ViewRun,
618 HookTest,
620 KeepSetAside,
622 DropSetAside,
624 FixSetup,
626 Feature,
628 PullRequest,
630 BugFix,
632 BugConfirm,
634 BugRun,
636 BugKeep,
638 AllowAlways,
640 WidenPolicy,
642 DecideBatch,
644 OracleTests,
646 ApproveAndRun,
648 Comment,
650 CommentsSend,
652 RevisionUndo,
654 CodeNudge,
656 AcceptanceAt,
658 RuleScenario,
660 CheckStory,
662 AcceptUntestable,
664 PresetAdd,
666 PlanThrough,
668 NewProject,
670 UndoNewProject,
672 CloneRepository,
674 OpenSample,
676 SampleChecks,
678 DeclinePromotion,
680 Explore,
682 ExploreChoose,
684 Command,
686}
687
688impl ActionKind {
689 pub const ALL: &'static [Self] = &[
691 Self::Skill,
692 Self::RunTask,
693 Self::RunFeature,
694 Self::Converge,
695 Self::Commit,
696 Self::Approve,
697 Self::Reject,
698 Self::Accept,
699 Self::Skip,
700 Self::Nudge,
701 Self::Done,
702 Self::Hint,
703 Self::Rule,
704 Self::Review,
705 Self::IssueComment,
706 Self::QuickChange,
707 Self::HookTrust,
708 Self::ExtensionTrust,
709 Self::ViewRun,
710 Self::HookTest,
711 Self::KeepSetAside,
712 Self::DropSetAside,
713 Self::FixSetup,
714 Self::Feature,
715 Self::PullRequest,
716 Self::BugFix,
717 Self::BugConfirm,
718 Self::BugRun,
719 Self::BugKeep,
720 Self::AllowAlways,
721 Self::WidenPolicy,
722 Self::DecideBatch,
723 Self::OracleTests,
724 Self::ApproveAndRun,
725 Self::Comment,
726 Self::CommentsSend,
727 Self::RevisionUndo,
728 Self::CodeNudge,
729 Self::AcceptanceAt,
730 Self::RuleScenario,
731 Self::CheckStory,
732 Self::AcceptUntestable,
733 Self::PresetAdd,
734 Self::PlanThrough,
735 Self::NewProject,
736 Self::UndoNewProject,
737 Self::CloneRepository,
738 Self::OpenSample,
739 Self::SampleChecks,
740 Self::DeclinePromotion,
741 Self::Explore,
742 Self::ExploreChoose,
743 Self::Command,
744 ];
745
746 #[must_use]
748 pub const fn as_str(self) -> &'static str {
749 match self {
750 Self::Skill => "skill",
751 Self::RunTask => "run_task",
752 Self::RunFeature => "run_feature",
753 Self::Converge => "converge",
754 Self::Commit => "commit",
755 Self::Approve => "approve",
756 Self::Reject => "reject",
757 Self::Accept => "accept",
758 Self::Skip => "skip",
759 Self::Nudge => "nudge",
760 Self::Done => "done",
761 Self::Hint => "hint",
762 Self::Rule => "rule",
763 Self::Review => "review",
764 Self::IssueComment => "issue_comment",
765 Self::QuickChange => "quick_change",
766 Self::HookTrust => "hook_trust",
767 Self::ExtensionTrust => "extension_trust",
768 Self::ViewRun => "view_run",
769 Self::HookTest => "hook_test",
770 Self::KeepSetAside => "keep_set_aside",
771 Self::DropSetAside => "drop_set_aside",
772 Self::FixSetup => "fix_setup",
773 Self::Feature => "feature",
774 Self::PullRequest => "pull_request",
775 Self::BugFix => "bug_fix",
776 Self::BugConfirm => "bug_confirm",
777 Self::BugRun => "bug_run",
778 Self::BugKeep => "bug_keep",
779 Self::AllowAlways => "allow_always",
780 Self::WidenPolicy => "widen_policy",
781 Self::DecideBatch => "decide_batch",
782 Self::OracleTests => "oracle_tests",
783 Self::ApproveAndRun => "approve_and_run",
784 Self::Comment => "comment",
785 Self::CommentsSend => "comments_send",
786 Self::RevisionUndo => "revision_undo",
787 Self::CodeNudge => "code_nudge",
788 Self::AcceptanceAt => "acceptance_at",
789 Self::RuleScenario => "rule_scenario",
790 Self::CheckStory => "check_story",
791 Self::AcceptUntestable => "accept_untestable",
792 Self::PresetAdd => "preset_add",
793 Self::PlanThrough => "plan_through",
794 Self::NewProject => "new_project",
795 Self::UndoNewProject => "undo_new_project",
796 Self::CloneRepository => "clone_repository",
797 Self::OpenSample => "open_sample",
798 Self::SampleChecks => "sample_checks",
799 Self::DeclinePromotion => "decline_promotion",
800 Self::Explore => "explore",
801 Self::ExploreChoose => "explore_choose",
802 Self::Command => "command",
803 }
804 }
805
806 #[must_use]
809 pub fn example(self) -> Option<Runnable> {
810 let task = || "T001".to_owned();
811 Some(match self {
812 Self::Skill => Runnable::Skill {
813 name: "plan".to_owned(),
814 arguments: String::new(),
815 },
816 Self::RunTask => Runnable::Run {
817 task: Some(task()),
818 agent: None,
819 cap: None,
820 },
821 Self::RunFeature => Runnable::Run {
822 task: None,
823 agent: None,
824 cap: None,
825 },
826 Self::Converge => Runnable::Converge,
827 Self::Commit => Runnable::Commit {
828 paths: vec!["plan.md".to_owned()],
829 },
830 Self::Approve => Runnable::Approve {
831 task: task(),
832 call: "0f3c".to_owned(),
833 digest: "d1".to_owned(),
834 },
835 Self::Reject => Runnable::Reject {
836 task: task(),
837 call: "0f3c".to_owned(),
838 digest: "d1".to_owned(),
839 },
840 Self::Accept => Runnable::Accept { task: task() },
841 Self::Skip => Runnable::Skip { task: task() },
842 Self::Nudge => Runnable::Nudge { task: task() },
843 Self::Done => Runnable::Done { task: task() },
844 Self::Hint => Runnable::Hint { task: task() },
845 Self::Rule => Runnable::Rule { task: task() },
846 Self::Review => Runnable::Review {
847 ids: vec!["FR-001".to_owned()],
848 verdict: "approve".to_owned(),
849 },
850 Self::IssueComment => Runnable::IssueComment {
851 issue: 42,
852 assign: false,
853 },
854 Self::QuickChange => Runnable::QuickChange {
855 request: "a change".to_owned(),
856 checks: Vec::new(),
857 run: false,
858 asked: None,
859 },
860 Self::HookTrust => Runnable::HookTrust {
861 name: "licence".to_owned(),
862 digest: "sha256:00".to_owned(),
863 },
864 Self::ExtensionTrust => Runnable::ExtensionTrust {
865 name: "threatspec".to_owned(),
866 digest: "sha256:00".to_owned(),
867 },
868 Self::ViewRun => Runnable::ViewRun {
869 extension: "threatspec".to_owned(),
870 view: "findings".to_owned(),
871 feature: None,
872 },
873 Self::HookTest => Runnable::HookTest {
874 name: "licence".to_owned(),
875 },
876 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
877 Self::DropSetAside => Runnable::DropSetAside { task: task() },
878 Self::FixSetup => Runnable::FixSetup,
879 Self::Feature => Runnable::Feature,
880 Self::PullRequest => Runnable::PullRequest,
881 Self::BugFix => Runnable::BugFix {
882 symptom: "saving an empty title crashes".to_owned(),
883 expected: vec!["an empty title is refused".to_owned()],
884 unchanged: Vec::new(),
885 issue: None,
886 tests: Vec::new(),
887 candidate: None,
888 slug: None,
889 run: false,
890 asked: None,
891 },
892 Self::BugConfirm => Runnable::BugConfirm {
893 task: "007-bugfixes/T001".to_owned(),
894 digest: "sha256:0".to_owned(),
895 reject: false,
896 },
897 Self::BugRun => Runnable::BugRun {
898 slug: None,
899 task: Some("007-bugfixes/T001".to_owned()),
900 },
901 Self::BugKeep => Runnable::BugKeep {
902 task: "007-bugfixes/T001".to_owned(),
903 },
904 Self::AllowAlways => Runnable::AllowAlways {
905 task: task(),
906 call: "0f3c".to_owned(),
907 },
908 Self::WidenPolicy => Runnable::WidenPolicy {
909 key: PolicyList::CommandsPrefix,
910 prefix: vec!["npm".to_owned(), "run".to_owned()],
911 digest: "d1".to_owned(),
912 },
913 Self::DecideBatch => Runnable::DecideBatch {
914 run: "run-1-0a0b0c0d".to_owned(),
915 verdict: BatchVerdict::Approve,
916 calls: vec!["0f3c".to_owned()],
917 digest: "d1".to_owned(),
918 resume_asking: false,
919 },
920 Self::OracleTests => Runnable::OracleTests {
921 req: "US1".to_owned(),
922 op: crate::oracle::OracleOp::Write,
923 path: None,
924 scenario: None,
925 reading: None,
926 },
927 Self::ApproveAndRun => Runnable::ApproveAndRun {
928 mode: crate::oracle::RunMode::Ask,
929 grants: Vec::new(),
930 sheet: None,
931 },
932 Self::Comment => Runnable::Comment {
933 op: CommentOp::Delete,
934 anchor: None,
935 id: Some("c1".to_owned()),
936 },
937 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
938 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
939 Self::CodeNudge => Runnable::CodeNudge {
940 task: task(),
941 comments: vec![crate::oracle::CodeComment {
942 path: "src/lib.rs".to_owned(),
943 line: 1,
944 quote: "pub fn ok() {}".to_owned(),
945 text: "name it".to_owned(),
946 }],
947 },
948 Self::AcceptanceAt => Runnable::AcceptanceAt {
949 feature: "001-greet-function".to_owned(),
950 story: "US1".to_owned(),
951 task: "T003".to_owned(),
952 revoke: false,
953 },
954 Self::CheckStory => Runnable::CheckStory {
955 story: "US3".to_owned(),
956 },
957 Self::AcceptUntestable => Runnable::AcceptUntestable {
958 scenario: "US2-2".to_owned(),
959 },
960 Self::RuleScenario => Runnable::RuleScenario {
961 task: "T021".to_owned(),
962 scenario: Some("US3-2".to_owned()),
963 },
964 Self::PresetAdd => Runnable::PresetAdd { update: false },
965 Self::PlanThrough => Runnable::PlanThrough {
966 from: PlanFrom::Create {
967 description: "Users can export a report as CSV".to_owned(),
968 source: FeatureSource::Description,
969 },
970 through: Through::Tasks,
971 },
972 Self::NewProject => Runnable::NewProject {
973 target: "/tmp/md2csv".to_owned(),
974 choices: crate::new_project::NewChoices {
975 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
976 ..crate::new_project::NewChoices::default()
977 },
978 plan_digest: "sha256:0".to_owned(),
979 },
980 Self::UndoNewProject => Runnable::UndoNewProject {
981 commit: "1a2b3c4".to_owned(),
982 },
983 Self::CloneRepository => Runnable::CloneRepository {
984 repo: "octo/hello".to_owned(),
985 target: "/tmp/hello".to_owned(),
986 },
987 Self::OpenSample => Runnable::OpenSample { fresh: false },
988 Self::SampleChecks => Runnable::SampleChecks,
989 Self::DeclinePromotion => Runnable::DeclinePromotion {
990 task: "004-quick-changes/T002".to_owned(),
991 },
992 Self::Explore => Runnable::Explore {
993 idea: "export reports as CSV".to_owned(),
994 skill: None,
995 resume: None,
996 },
997 Self::ExploreChoose => Runnable::ExploreChoose {
998 session: "ex-1a2b3c4d5e6f".to_owned(),
999 approach: 2,
1000 constraints: vec!["no new dependency".to_owned()],
1001 questions: Vec::new(),
1002 flag: false,
1003 },
1004 Self::Command => return None,
1005 })
1006 }
1007}
1008
1009impl Runnable {
1010 #[must_use]
1012 pub fn examples() -> Vec<Self> {
1013 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
1014 }
1015
1016 #[must_use]
1018 pub const fn kind(&self) -> ActionKind {
1019 match self {
1020 Self::Skill { .. } => ActionKind::Skill,
1021 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
1022 Self::Run { task: None, .. } => ActionKind::RunFeature,
1023 Self::Converge => ActionKind::Converge,
1024 Self::Commit { .. } => ActionKind::Commit,
1025 Self::Approve { .. } => ActionKind::Approve,
1026 Self::Reject { .. } => ActionKind::Reject,
1027 Self::Accept { .. } => ActionKind::Accept,
1028 Self::Skip { .. } => ActionKind::Skip,
1029 Self::Nudge { .. } => ActionKind::Nudge,
1030 Self::Done { .. } => ActionKind::Done,
1031 Self::Hint { .. } => ActionKind::Hint,
1032 Self::Rule { .. } => ActionKind::Rule,
1033 Self::Review { .. } => ActionKind::Review,
1034 Self::IssueComment { .. } => ActionKind::IssueComment,
1035 Self::QuickChange { .. } => ActionKind::QuickChange,
1036 Self::HookTrust { .. } => ActionKind::HookTrust,
1037 Self::ExtensionTrust { .. } => ActionKind::ExtensionTrust,
1038 Self::ViewRun { .. } => ActionKind::ViewRun,
1039 Self::HookTest { .. } => ActionKind::HookTest,
1040 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
1041 Self::DropSetAside { .. } => ActionKind::DropSetAside,
1042 Self::FixSetup => ActionKind::FixSetup,
1043 Self::Feature => ActionKind::Feature,
1044 Self::PullRequest => ActionKind::PullRequest,
1045 Self::BugFix { .. } => ActionKind::BugFix,
1046 Self::BugConfirm { .. } => ActionKind::BugConfirm,
1047 Self::BugRun { .. } => ActionKind::BugRun,
1048 Self::BugKeep { .. } => ActionKind::BugKeep,
1049 Self::AllowAlways { .. } => ActionKind::AllowAlways,
1050 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
1051 Self::DecideBatch { .. } => ActionKind::DecideBatch,
1052 Self::OracleTests { .. } => ActionKind::OracleTests,
1053 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
1054 Self::Comment { .. } => ActionKind::Comment,
1055 Self::CommentsSend { .. } => ActionKind::CommentsSend,
1056 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
1057 Self::CodeNudge { .. } => ActionKind::CodeNudge,
1058 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
1059 Self::RuleScenario { .. } => ActionKind::RuleScenario,
1060 Self::CheckStory { .. } => ActionKind::CheckStory,
1061 Self::AcceptUntestable { .. } => ActionKind::AcceptUntestable,
1062 Self::PresetAdd { .. } => ActionKind::PresetAdd,
1063 Self::PlanThrough { .. } => ActionKind::PlanThrough,
1064 Self::NewProject { .. } => ActionKind::NewProject,
1065 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
1066 Self::CloneRepository { .. } => ActionKind::CloneRepository,
1067 Self::OpenSample { .. } => ActionKind::OpenSample,
1068 Self::SampleChecks => ActionKind::SampleChecks,
1069 Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
1070 Self::Explore { .. } => ActionKind::Explore,
1071 Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
1072 }
1073 }
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1080pub struct ActionId {
1081 pub kind: ActionKind,
1083 pub subject: Option<String>,
1086}
1087
1088#[must_use]
1091pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
1092 let Some(run) = run else {
1093 let words: Vec<&str> = command
1095 .split_whitespace()
1096 .skip_while(|w| *w == "speccoder")
1097 .take_while(|w| {
1098 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1099 && w.chars()
1100 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1101 })
1102 .collect();
1103 return ActionId {
1104 kind: ActionKind::Command,
1105 subject: (!words.is_empty()).then(|| words.join(" ")),
1106 };
1107 };
1108 let subject = match run {
1109 Runnable::Skill { name, .. } => Some(name.clone()),
1110 Runnable::Run { task, .. } => task.clone(),
1111 Runnable::Approve { task, .. }
1112 | Runnable::Reject { task, .. }
1113 | Runnable::Accept { task }
1114 | Runnable::Skip { task }
1115 | Runnable::Nudge { task }
1116 | Runnable::Done { task }
1117 | Runnable::Hint { task }
1118 | Runnable::Rule { task }
1119 | Runnable::KeepSetAside { task }
1120 | Runnable::DropSetAside { task }
1121 | Runnable::AllowAlways { task, .. }
1122 | Runnable::CodeNudge { task, .. }
1123 | Runnable::AcceptanceAt { task, .. }
1124 | Runnable::RuleScenario { task, .. }
1125 | Runnable::DeclinePromotion { task } => Some(task.clone()),
1126 Runnable::Explore { resume, .. } => resume.clone(),
1127 Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1128 Runnable::OracleTests { req, .. } => Some(req.clone()),
1129 Runnable::CheckStory { story } => Some(story.clone()),
1130 Runnable::HookTrust { name, .. }
1131 | Runnable::HookTest { name }
1132 | Runnable::ExtensionTrust { name, .. } => Some(name.clone()),
1133 Runnable::ViewRun {
1134 extension, view, ..
1135 } => Some(format!("{extension}.{view}")),
1136 Runnable::AcceptUntestable { scenario } => Some(scenario.clone()),
1137 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1138 Runnable::Comment { id, .. } => id.clone(),
1139 Runnable::RevisionUndo { turn } => turn.clone(),
1140 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1141 Runnable::DecideBatch { run, .. } => Some(run.clone()),
1142 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1143 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1144 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1145 Runnable::BugFix { slug, issue, .. } => {
1146 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1147 }
1148 Runnable::Review { ids, verdict } => Some(match ids.first() {
1149 Some(id) => format!("{verdict}:{id}"),
1150 None => verdict.clone(),
1151 }),
1152 Runnable::PlanThrough { from, through } => Some(match from {
1153 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1154 PlanFrom::Continue => format!("continue:{}", through.as_str()),
1155 }),
1156 Runnable::NewProject { target, .. } => Some(target.clone()),
1157 Runnable::UndoNewProject { commit } => Some(commit.clone()),
1158 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1159 Runnable::Converge
1160 | Runnable::OpenSample { .. }
1161 | Runnable::SampleChecks
1162 | Runnable::Commit { .. }
1163 | Runnable::QuickChange { .. }
1164 | Runnable::FixSetup
1165 | Runnable::Feature
1166 | Runnable::PullRequest
1167 | Runnable::PresetAdd { .. }
1168 | Runnable::CommentsSend { .. } => None,
1169 };
1170 ActionId {
1171 kind: run.kind(),
1172 subject,
1173 }
1174}
1175
1176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1180#[serde(rename_all = "snake_case")]
1181pub enum SkillRole {
1182 Create,
1184 TowardTasks,
1186 Revise,
1188 Check,
1190 Other,
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1199#[serde(rename_all = "snake_case")]
1200pub enum Via {
1201 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1203 Terminal,
1204 Web,
1206 Window,
1208 Acp,
1210 Phone,
1212 Ci,
1214 Mcp,
1216}
1217
1218impl Via {
1219 #[must_use]
1221 pub const fn as_str(self) -> &'static str {
1222 match self {
1223 Self::Terminal => "terminal",
1224 Self::Web => "web",
1225 Self::Window => "window",
1226 Self::Acp => "acp",
1227 Self::Phone => "phone",
1228 Self::Ci => "ci",
1229 Self::Mcp => "mcp",
1230 }
1231 }
1232
1233 #[must_use]
1236 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1237 if let Some(client) = recorded.strip_prefix("editor ") {
1238 return Some((Self::Acp, Some(client.to_owned())));
1239 }
1240 let via = match recorded {
1241 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1242 "web" => Self::Web,
1243 "window" => Self::Window,
1244 "acp" | "editor" => Self::Acp,
1245 "phone" => Self::Phone,
1246 "ci" => Self::Ci,
1247 "mcp" => Self::Mcp,
1248 _ => return None,
1249 };
1250 Some((via, None))
1251 }
1252}
1253
1254impl std::fmt::Display for Via {
1255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1256 f.write_str(self.as_str())
1257 }
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1262pub struct Action {
1263 pub group: String,
1265 pub label: String,
1267 pub why: String,
1269 pub command: String,
1271 pub run: Option<Runnable>,
1273 pub step: Option<crate::step::How>,
1276 pub blocked: Option<String>,
1278 pub input: Option<String>,
1280 pub confirm: Option<String>,
1283 pub id: ActionId,
1285 pub role: Option<SkillRole>,
1288}
1289
1290impl Action {
1291 #[must_use]
1293 pub fn new(
1294 group: &str,
1295 label: impl Into<String>,
1296 why: impl Into<String>,
1297 command: impl Into<String>,
1298 ) -> Self {
1299 let command = command.into();
1300 Self {
1301 group: group.to_owned(),
1302 label: label.into(),
1303 why: why.into(),
1304 id: identity(None, &command),
1305 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1306 command,
1307 run: None,
1308 blocked: None,
1309 input: None,
1310 confirm: None,
1311 role: None,
1312 }
1313 }
1314
1315 #[must_use]
1317 pub fn with_run(mut self, run: Runnable) -> Self {
1318 self.id = identity(Some(&run), &self.command);
1319 self.run = Some(run);
1320 self.step = None;
1321 self
1322 }
1323
1324 #[must_use]
1326 pub const fn with_role(mut self, role: SkillRole) -> Self {
1327 self.role = Some(role);
1328 self
1329 }
1330
1331 #[must_use]
1333 pub const fn runnable(&self) -> bool {
1334 self.run.is_some() && self.blocked.is_none()
1335 }
1336}
1337
1338#[must_use]
1340pub fn primary(actions: &[Action]) -> Option<&Action> {
1341 actions.iter().find(|a| a.group == "next" && a.runnable())
1342}
1343
1344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1346#[serde(rename_all = "snake_case")]
1347pub enum StepState {
1348 Done,
1350 Now,
1352 Todo,
1354 Skipped,
1357}
1358
1359#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1362pub struct JourneyStep {
1363 pub name: String,
1365 pub state: StepState,
1367}
1368
1369#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1371pub struct JourneyFacts {
1372 pub spec: bool,
1374 pub plan: bool,
1376 pub tasks: bool,
1378 pub uncommitted: bool,
1380 pub total: usize,
1382 pub finished: usize,
1384}
1385
1386#[must_use]
1390pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1391 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1392 let done = [
1393 ("spec", f.spec),
1394 ("plan", f.plan),
1395 ("tasks", f.tasks && f.total > 0),
1397 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1398 ("run", f.total > 0 && f.finished == f.total),
1399 ("review", false),
1400 ];
1401 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1402 let now = done
1403 .iter()
1404 .enumerate()
1405 .position(|(i, (_, d))| !d && !passed(i))
1406 .unwrap_or(done.len());
1407 done.iter()
1408 .enumerate()
1409 .map(|(i, (name, d))| JourneyStep {
1410 name: (*name).to_owned(),
1411 state: if *d && i < now {
1412 StepState::Done
1413 } else if passed(i) && i < now {
1414 StepState::Skipped
1415 } else if i == now {
1416 StepState::Now
1417 } else {
1418 StepState::Todo
1419 },
1420 })
1421 .collect()
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427
1428 #[test]
1429 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1430 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1432 .unwrap_or_else(|e| panic!("{e}"));
1433 let mut named: Vec<String> = schema["oneOf"]
1434 .as_array()
1435 .into_iter()
1436 .flatten()
1437 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1438 .collect();
1439 named.sort();
1440 let mut listed: Vec<String> = ActionKind::ALL
1441 .iter()
1442 .map(|k| k.as_str().to_owned())
1443 .collect();
1444 listed.sort();
1445 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1446 for k in ActionKind::ALL {
1447 assert_eq!(
1448 serde_json::to_value(k).unwrap_or_default(),
1449 k.as_str(),
1450 "{k:?}"
1451 );
1452 if let Some(run) = k.example() {
1453 assert_eq!(run.kind(), *k);
1454 assert_eq!(identity(Some(&run), "").kind, *k);
1455 }
1456 }
1457 assert_eq!(
1458 Runnable::examples().len(),
1459 ActionKind::ALL.len() - 1,
1460 "every kind but `command` has a runnable"
1461 );
1462 }
1463
1464 #[test]
1465 fn identity_is_what_runs_never_how_it_is_worded() {
1466 let a = Action::new(
1467 "task",
1468 "accept T004",
1469 "tick it",
1470 "speccoder task accept T004",
1471 )
1472 .with_run(Runnable::Accept {
1473 task: "T004".to_owned(),
1474 });
1475 let mut b = a.clone();
1476 b.label = "x tick it anyway".to_owned();
1477 b.why = "x".to_owned();
1478 assert_eq!(a.id, b.id);
1479 assert_eq!(
1480 a.id,
1481 ActionId {
1482 kind: ActionKind::Accept,
1483 subject: Some("T004".to_owned())
1484 }
1485 );
1486 let id = |c: &str| identity(None, c).subject;
1487 assert_eq!(
1488 id("speccoder attest verify").as_deref(),
1489 Some("attest verify")
1490 );
1491 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1492 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1493 assert_eq!(
1494 id("speccoder spec specify \"…\"").as_deref(),
1495 Some("spec specify")
1496 );
1497 let review = |ids: &[&str], v: &str| {
1498 identity(
1499 Some(&Runnable::Review {
1500 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1501 verdict: v.to_owned(),
1502 }),
1503 "",
1504 )
1505 .subject
1506 };
1507 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1508 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1509 }
1510
1511 #[test]
1512 fn origins_read_back_from_every_value_ever_recorded() {
1513 for (old, via, client) in [
1514 ("cli", Via::Terminal, None),
1515 ("tui", Via::Terminal, None),
1516 ("dashboard", Via::Terminal, None),
1517 ("terminal", Via::Terminal, None),
1518 ("web", Via::Web, None),
1519 ("editor Zed", Via::Acp, Some("Zed")),
1520 ] {
1521 assert_eq!(
1522 Via::read(old),
1523 Some((via, client.map(str::to_owned))),
1524 "{old}"
1525 );
1526 }
1527 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1528 assert_eq!(parsed, Via::Terminal);
1529 assert_eq!(Via::Acp.as_str(), "acp");
1530 assert_eq!(Via::read("nobody"), None);
1531 }
1532
1533 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1534 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1535 }
1536
1537 #[test]
1538 fn the_journey_marks_what_is_done_and_what_is_now() {
1539 use StepState::{Done, Now, Todo};
1540 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1541 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1542 assert_eq!(
1543 states(JourneyFacts::default()),
1544 at([Now, Todo, Todo, Todo, Todo, Todo])
1545 );
1546 let planned = JourneyFacts {
1547 spec: true,
1548 plan: true,
1549 ..JourneyFacts::default()
1550 };
1551 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1552 let written = JourneyFacts {
1553 tasks: true,
1554 uncommitted: true,
1555 total: 3,
1556 ..planned
1557 };
1558 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1559 let running = JourneyFacts {
1560 uncommitted: false,
1561 finished: 1,
1562 ..written
1563 };
1564 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1565 let finished = JourneyFacts {
1566 finished: 3,
1567 ..running
1568 };
1569 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1570 let empty = JourneyFacts {
1572 tasks: true,
1573 ..planned
1574 };
1575 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1576 }
1577
1578 #[test]
1579 fn a_task_list_without_a_spec_skips_to_the_run() {
1580 use StepState::{Now, Skipped, Todo};
1581 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1582 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1583 let only_tasks = JourneyFacts {
1584 tasks: true,
1585 total: 4,
1586 finished: 1,
1587 ..JourneyFacts::default()
1588 };
1589 assert_eq!(
1590 states(only_tasks),
1591 at([
1592 Skipped,
1593 Skipped,
1594 StepState::Done,
1595 StepState::Done,
1596 Now,
1597 Todo
1598 ])
1599 );
1600 let planned = JourneyFacts {
1602 plan: true,
1603 ..JourneyFacts::default()
1604 };
1605 assert_eq!(
1606 states(planned),
1607 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1608 );
1609 }
1610}