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 HookTest {
127 name: String,
129 },
130 KeepSetAside {
133 task: String,
135 },
136 DropSetAside {
138 task: String,
140 },
141 FixSetup,
144 Feature,
146 PullRequest,
148 BugFix {
151 symptom: String,
153 expected: Vec<String>,
155 unchanged: Vec<String>,
157 issue: Option<u64>,
159 tests: Vec<String>,
162 candidate: Option<String>,
164 slug: Option<String>,
166 run: bool,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 asked: Option<String>,
171 },
172 BugConfirm {
175 task: String,
177 digest: String,
179 reject: bool,
181 },
182 BugKeep {
185 task: String,
187 },
188 BugRun {
190 slug: Option<String>,
192 task: Option<String>,
194 },
195 AllowAlways {
198 task: String,
200 call: String,
202 },
203 WidenPolicy {
206 key: PolicyList,
208 prefix: Vec<String>,
210 digest: String,
212 },
213 DecideBatch {
215 run: String,
217 verdict: BatchVerdict,
219 calls: Vec<String>,
221 digest: String,
223 resume_asking: bool,
225 },
226 OracleTests {
229 req: String,
231 op: crate::oracle::OracleOp,
233 path: Option<String>,
235 scenario: Option<String>,
237 reading: Option<crate::oracle::Reading>,
239 },
240 ApproveAndRun {
243 mode: crate::oracle::RunMode,
245 grants: Vec<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
250 sheet: Option<String>,
251 },
252 Comment {
254 op: CommentOp,
256 anchor: Option<crate::oracle::CommentAnchor>,
258 id: Option<String>,
260 },
261 CommentsSend {
263 ids: Vec<String>,
266 },
267 RevisionUndo {
269 turn: Option<String>,
271 },
272 CodeNudge {
274 task: String,
276 comments: Vec<crate::oracle::CodeComment>,
278 },
279 AcceptanceAt {
283 feature: String,
285 story: String,
287 task: String,
289 revoke: bool,
291 },
292 CheckStory {
295 story: String,
297 },
298 RuleScenario {
302 task: String,
304 scenario: Option<String>,
306 },
307 AcceptUntestable {
311 scenario: String,
313 },
314 PresetAdd {
317 update: bool,
319 },
320 PlanThrough {
324 from: PlanFrom,
326 through: Through,
328 },
329 NewProject {
332 target: String,
334 choices: crate::new_project::NewChoices,
336 plan_digest: String,
338 },
339 UndoNewProject {
342 commit: String,
344 },
345 CloneRepository {
347 repo: String,
349 target: String,
351 },
352 OpenSample {
355 fresh: bool,
357 },
358 SampleChecks,
361 DeclinePromotion {
364 task: String,
366 },
367 Explore {
370 idea: String,
372 #[serde(default)]
374 skill: Option<String>,
375 #[serde(default)]
377 resume: Option<String>,
378 },
379 ExploreChoose {
382 session: String,
384 approach: usize,
386 #[serde(default)]
388 constraints: Vec<String>,
389 #[serde(default)]
391 questions: Vec<String>,
392 #[serde(default)]
394 flag: bool,
395 },
396}
397
398#[derive(
400 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
401)]
402#[serde(rename_all = "snake_case")]
403pub enum Through {
404 Spec,
406 Plan,
408 Tasks,
410 Analyze,
412}
413
414impl Through {
415 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
417
418 #[must_use]
420 pub const fn as_str(self) -> &'static str {
421 match self {
422 Self::Spec => "spec",
423 Self::Plan => "plan",
424 Self::Tasks => "tasks",
425 Self::Analyze => "analyze",
426 }
427 }
428
429 #[must_use]
431 pub fn parse(s: &str) -> Option<Self> {
432 Self::ALL.into_iter().find(|t| t.as_str() == s)
433 }
434}
435
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
439#[serde(tag = "kind", rename_all = "snake_case")]
440pub enum FeatureSource {
441 Description,
443 Issue {
445 number: u64,
447 },
448 Promotion {
451 task: String,
453 #[serde(default)]
455 conversations: Vec<String>,
456 },
457 Explore {
459 session: String,
461 digest: String,
463 },
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
468#[serde(tag = "kind", rename_all = "snake_case")]
469pub enum PlanFrom {
470 Create {
472 description: String,
474 source: FeatureSource,
476 },
477 Continue,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
483#[serde(rename_all = "snake_case")]
484pub enum CommentOp {
485 Add,
487 Delete,
489 Keep,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
495#[serde(rename_all = "snake_case")]
496pub enum PolicyList {
497 CommandsPrefix,
499 NetworkPrefix,
501}
502
503impl PolicyList {
504 #[must_use]
506 pub const fn as_str(self) -> &'static str {
507 match self {
508 Self::CommandsPrefix => "commands_prefix",
509 Self::NetworkPrefix => "network_prefix",
510 }
511 }
512
513 #[must_use]
515 pub const fn key(self) -> &'static str {
516 match self {
517 Self::CommandsPrefix => "commands.allow_prefix",
518 Self::NetworkPrefix => "sandbox.network_allow_prefix",
519 }
520 }
521
522 #[must_use]
524 pub fn of_key(key: &str) -> Option<Self> {
525 match key {
526 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
527 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
528 _ => None,
529 }
530 }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
535#[serde(rename_all = "snake_case")]
536pub enum BatchVerdict {
537 Approve,
539 Reject,
541}
542
543#[derive(
549 Debug,
550 Clone,
551 Copy,
552 PartialEq,
553 Eq,
554 Hash,
555 PartialOrd,
556 Ord,
557 Serialize,
558 Deserialize,
559 schemars::JsonSchema,
560)]
561#[serde(rename_all = "snake_case")]
562pub enum ActionKind {
563 Skill,
565 RunTask,
567 RunFeature,
569 Converge,
571 Commit,
573 Approve,
575 Reject,
577 Accept,
579 Skip,
581 Nudge,
583 Done,
585 Hint,
587 Rule,
589 Review,
591 IssueComment,
593 QuickChange,
595 HookTrust,
597 HookTest,
599 KeepSetAside,
601 DropSetAside,
603 FixSetup,
605 Feature,
607 PullRequest,
609 BugFix,
611 BugConfirm,
613 BugRun,
615 BugKeep,
617 AllowAlways,
619 WidenPolicy,
621 DecideBatch,
623 OracleTests,
625 ApproveAndRun,
627 Comment,
629 CommentsSend,
631 RevisionUndo,
633 CodeNudge,
635 AcceptanceAt,
637 RuleScenario,
639 CheckStory,
641 AcceptUntestable,
643 PresetAdd,
645 PlanThrough,
647 NewProject,
649 UndoNewProject,
651 CloneRepository,
653 OpenSample,
655 SampleChecks,
657 DeclinePromotion,
659 Explore,
661 ExploreChoose,
663 Command,
665}
666
667impl ActionKind {
668 pub const ALL: &'static [Self] = &[
670 Self::Skill,
671 Self::RunTask,
672 Self::RunFeature,
673 Self::Converge,
674 Self::Commit,
675 Self::Approve,
676 Self::Reject,
677 Self::Accept,
678 Self::Skip,
679 Self::Nudge,
680 Self::Done,
681 Self::Hint,
682 Self::Rule,
683 Self::Review,
684 Self::IssueComment,
685 Self::QuickChange,
686 Self::HookTrust,
687 Self::HookTest,
688 Self::KeepSetAside,
689 Self::DropSetAside,
690 Self::FixSetup,
691 Self::Feature,
692 Self::PullRequest,
693 Self::BugFix,
694 Self::BugConfirm,
695 Self::BugRun,
696 Self::BugKeep,
697 Self::AllowAlways,
698 Self::WidenPolicy,
699 Self::DecideBatch,
700 Self::OracleTests,
701 Self::ApproveAndRun,
702 Self::Comment,
703 Self::CommentsSend,
704 Self::RevisionUndo,
705 Self::CodeNudge,
706 Self::AcceptanceAt,
707 Self::RuleScenario,
708 Self::CheckStory,
709 Self::AcceptUntestable,
710 Self::PresetAdd,
711 Self::PlanThrough,
712 Self::NewProject,
713 Self::UndoNewProject,
714 Self::CloneRepository,
715 Self::OpenSample,
716 Self::SampleChecks,
717 Self::DeclinePromotion,
718 Self::Explore,
719 Self::ExploreChoose,
720 Self::Command,
721 ];
722
723 #[must_use]
725 pub const fn as_str(self) -> &'static str {
726 match self {
727 Self::Skill => "skill",
728 Self::RunTask => "run_task",
729 Self::RunFeature => "run_feature",
730 Self::Converge => "converge",
731 Self::Commit => "commit",
732 Self::Approve => "approve",
733 Self::Reject => "reject",
734 Self::Accept => "accept",
735 Self::Skip => "skip",
736 Self::Nudge => "nudge",
737 Self::Done => "done",
738 Self::Hint => "hint",
739 Self::Rule => "rule",
740 Self::Review => "review",
741 Self::IssueComment => "issue_comment",
742 Self::QuickChange => "quick_change",
743 Self::HookTrust => "hook_trust",
744 Self::HookTest => "hook_test",
745 Self::KeepSetAside => "keep_set_aside",
746 Self::DropSetAside => "drop_set_aside",
747 Self::FixSetup => "fix_setup",
748 Self::Feature => "feature",
749 Self::PullRequest => "pull_request",
750 Self::BugFix => "bug_fix",
751 Self::BugConfirm => "bug_confirm",
752 Self::BugRun => "bug_run",
753 Self::BugKeep => "bug_keep",
754 Self::AllowAlways => "allow_always",
755 Self::WidenPolicy => "widen_policy",
756 Self::DecideBatch => "decide_batch",
757 Self::OracleTests => "oracle_tests",
758 Self::ApproveAndRun => "approve_and_run",
759 Self::Comment => "comment",
760 Self::CommentsSend => "comments_send",
761 Self::RevisionUndo => "revision_undo",
762 Self::CodeNudge => "code_nudge",
763 Self::AcceptanceAt => "acceptance_at",
764 Self::RuleScenario => "rule_scenario",
765 Self::CheckStory => "check_story",
766 Self::AcceptUntestable => "accept_untestable",
767 Self::PresetAdd => "preset_add",
768 Self::PlanThrough => "plan_through",
769 Self::NewProject => "new_project",
770 Self::UndoNewProject => "undo_new_project",
771 Self::CloneRepository => "clone_repository",
772 Self::OpenSample => "open_sample",
773 Self::SampleChecks => "sample_checks",
774 Self::DeclinePromotion => "decline_promotion",
775 Self::Explore => "explore",
776 Self::ExploreChoose => "explore_choose",
777 Self::Command => "command",
778 }
779 }
780
781 #[must_use]
784 pub fn example(self) -> Option<Runnable> {
785 let task = || "T001".to_owned();
786 Some(match self {
787 Self::Skill => Runnable::Skill {
788 name: "plan".to_owned(),
789 arguments: String::new(),
790 },
791 Self::RunTask => Runnable::Run {
792 task: Some(task()),
793 agent: None,
794 cap: None,
795 },
796 Self::RunFeature => Runnable::Run {
797 task: None,
798 agent: None,
799 cap: None,
800 },
801 Self::Converge => Runnable::Converge,
802 Self::Commit => Runnable::Commit {
803 paths: vec!["plan.md".to_owned()],
804 },
805 Self::Approve => Runnable::Approve {
806 task: task(),
807 call: "0f3c".to_owned(),
808 digest: "d1".to_owned(),
809 },
810 Self::Reject => Runnable::Reject {
811 task: task(),
812 call: "0f3c".to_owned(),
813 digest: "d1".to_owned(),
814 },
815 Self::Accept => Runnable::Accept { task: task() },
816 Self::Skip => Runnable::Skip { task: task() },
817 Self::Nudge => Runnable::Nudge { task: task() },
818 Self::Done => Runnable::Done { task: task() },
819 Self::Hint => Runnable::Hint { task: task() },
820 Self::Rule => Runnable::Rule { task: task() },
821 Self::Review => Runnable::Review {
822 ids: vec!["FR-001".to_owned()],
823 verdict: "approve".to_owned(),
824 },
825 Self::IssueComment => Runnable::IssueComment {
826 issue: 42,
827 assign: false,
828 },
829 Self::QuickChange => Runnable::QuickChange {
830 request: "a change".to_owned(),
831 checks: Vec::new(),
832 run: false,
833 asked: None,
834 },
835 Self::HookTrust => Runnable::HookTrust {
836 name: "licence".to_owned(),
837 digest: "sha256:00".to_owned(),
838 },
839 Self::HookTest => Runnable::HookTest {
840 name: "licence".to_owned(),
841 },
842 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
843 Self::DropSetAside => Runnable::DropSetAside { task: task() },
844 Self::FixSetup => Runnable::FixSetup,
845 Self::Feature => Runnable::Feature,
846 Self::PullRequest => Runnable::PullRequest,
847 Self::BugFix => Runnable::BugFix {
848 symptom: "saving an empty title crashes".to_owned(),
849 expected: vec!["an empty title is refused".to_owned()],
850 unchanged: Vec::new(),
851 issue: None,
852 tests: Vec::new(),
853 candidate: None,
854 slug: None,
855 run: false,
856 asked: None,
857 },
858 Self::BugConfirm => Runnable::BugConfirm {
859 task: "007-bugfixes/T001".to_owned(),
860 digest: "sha256:0".to_owned(),
861 reject: false,
862 },
863 Self::BugRun => Runnable::BugRun {
864 slug: None,
865 task: Some("007-bugfixes/T001".to_owned()),
866 },
867 Self::BugKeep => Runnable::BugKeep {
868 task: "007-bugfixes/T001".to_owned(),
869 },
870 Self::AllowAlways => Runnable::AllowAlways {
871 task: task(),
872 call: "0f3c".to_owned(),
873 },
874 Self::WidenPolicy => Runnable::WidenPolicy {
875 key: PolicyList::CommandsPrefix,
876 prefix: vec!["npm".to_owned(), "run".to_owned()],
877 digest: "d1".to_owned(),
878 },
879 Self::DecideBatch => Runnable::DecideBatch {
880 run: "run-1-0a0b0c0d".to_owned(),
881 verdict: BatchVerdict::Approve,
882 calls: vec!["0f3c".to_owned()],
883 digest: "d1".to_owned(),
884 resume_asking: false,
885 },
886 Self::OracleTests => Runnable::OracleTests {
887 req: "US1".to_owned(),
888 op: crate::oracle::OracleOp::Write,
889 path: None,
890 scenario: None,
891 reading: None,
892 },
893 Self::ApproveAndRun => Runnable::ApproveAndRun {
894 mode: crate::oracle::RunMode::Ask,
895 grants: Vec::new(),
896 sheet: None,
897 },
898 Self::Comment => Runnable::Comment {
899 op: CommentOp::Delete,
900 anchor: None,
901 id: Some("c1".to_owned()),
902 },
903 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
904 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
905 Self::CodeNudge => Runnable::CodeNudge {
906 task: task(),
907 comments: vec![crate::oracle::CodeComment {
908 path: "src/lib.rs".to_owned(),
909 line: 1,
910 quote: "pub fn ok() {}".to_owned(),
911 text: "name it".to_owned(),
912 }],
913 },
914 Self::AcceptanceAt => Runnable::AcceptanceAt {
915 feature: "001-greet-function".to_owned(),
916 story: "US1".to_owned(),
917 task: "T003".to_owned(),
918 revoke: false,
919 },
920 Self::CheckStory => Runnable::CheckStory {
921 story: "US3".to_owned(),
922 },
923 Self::AcceptUntestable => Runnable::AcceptUntestable {
924 scenario: "US2-2".to_owned(),
925 },
926 Self::RuleScenario => Runnable::RuleScenario {
927 task: "T021".to_owned(),
928 scenario: Some("US3-2".to_owned()),
929 },
930 Self::PresetAdd => Runnable::PresetAdd { update: false },
931 Self::PlanThrough => Runnable::PlanThrough {
932 from: PlanFrom::Create {
933 description: "Users can export a report as CSV".to_owned(),
934 source: FeatureSource::Description,
935 },
936 through: Through::Tasks,
937 },
938 Self::NewProject => Runnable::NewProject {
939 target: "/tmp/md2csv".to_owned(),
940 choices: crate::new_project::NewChoices {
941 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
942 ..crate::new_project::NewChoices::default()
943 },
944 plan_digest: "sha256:0".to_owned(),
945 },
946 Self::UndoNewProject => Runnable::UndoNewProject {
947 commit: "1a2b3c4".to_owned(),
948 },
949 Self::CloneRepository => Runnable::CloneRepository {
950 repo: "octo/hello".to_owned(),
951 target: "/tmp/hello".to_owned(),
952 },
953 Self::OpenSample => Runnable::OpenSample { fresh: false },
954 Self::SampleChecks => Runnable::SampleChecks,
955 Self::DeclinePromotion => Runnable::DeclinePromotion {
956 task: "004-quick-changes/T002".to_owned(),
957 },
958 Self::Explore => Runnable::Explore {
959 idea: "export reports as CSV".to_owned(),
960 skill: None,
961 resume: None,
962 },
963 Self::ExploreChoose => Runnable::ExploreChoose {
964 session: "ex-1a2b3c4d5e6f".to_owned(),
965 approach: 2,
966 constraints: vec!["no new dependency".to_owned()],
967 questions: Vec::new(),
968 flag: false,
969 },
970 Self::Command => return None,
971 })
972 }
973}
974
975impl Runnable {
976 #[must_use]
978 pub fn examples() -> Vec<Self> {
979 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
980 }
981
982 #[must_use]
984 pub const fn kind(&self) -> ActionKind {
985 match self {
986 Self::Skill { .. } => ActionKind::Skill,
987 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
988 Self::Run { task: None, .. } => ActionKind::RunFeature,
989 Self::Converge => ActionKind::Converge,
990 Self::Commit { .. } => ActionKind::Commit,
991 Self::Approve { .. } => ActionKind::Approve,
992 Self::Reject { .. } => ActionKind::Reject,
993 Self::Accept { .. } => ActionKind::Accept,
994 Self::Skip { .. } => ActionKind::Skip,
995 Self::Nudge { .. } => ActionKind::Nudge,
996 Self::Done { .. } => ActionKind::Done,
997 Self::Hint { .. } => ActionKind::Hint,
998 Self::Rule { .. } => ActionKind::Rule,
999 Self::Review { .. } => ActionKind::Review,
1000 Self::IssueComment { .. } => ActionKind::IssueComment,
1001 Self::QuickChange { .. } => ActionKind::QuickChange,
1002 Self::HookTrust { .. } => ActionKind::HookTrust,
1003 Self::HookTest { .. } => ActionKind::HookTest,
1004 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
1005 Self::DropSetAside { .. } => ActionKind::DropSetAside,
1006 Self::FixSetup => ActionKind::FixSetup,
1007 Self::Feature => ActionKind::Feature,
1008 Self::PullRequest => ActionKind::PullRequest,
1009 Self::BugFix { .. } => ActionKind::BugFix,
1010 Self::BugConfirm { .. } => ActionKind::BugConfirm,
1011 Self::BugRun { .. } => ActionKind::BugRun,
1012 Self::BugKeep { .. } => ActionKind::BugKeep,
1013 Self::AllowAlways { .. } => ActionKind::AllowAlways,
1014 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
1015 Self::DecideBatch { .. } => ActionKind::DecideBatch,
1016 Self::OracleTests { .. } => ActionKind::OracleTests,
1017 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
1018 Self::Comment { .. } => ActionKind::Comment,
1019 Self::CommentsSend { .. } => ActionKind::CommentsSend,
1020 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
1021 Self::CodeNudge { .. } => ActionKind::CodeNudge,
1022 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
1023 Self::RuleScenario { .. } => ActionKind::RuleScenario,
1024 Self::CheckStory { .. } => ActionKind::CheckStory,
1025 Self::AcceptUntestable { .. } => ActionKind::AcceptUntestable,
1026 Self::PresetAdd { .. } => ActionKind::PresetAdd,
1027 Self::PlanThrough { .. } => ActionKind::PlanThrough,
1028 Self::NewProject { .. } => ActionKind::NewProject,
1029 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
1030 Self::CloneRepository { .. } => ActionKind::CloneRepository,
1031 Self::OpenSample { .. } => ActionKind::OpenSample,
1032 Self::SampleChecks => ActionKind::SampleChecks,
1033 Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
1034 Self::Explore { .. } => ActionKind::Explore,
1035 Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
1036 }
1037 }
1038}
1039
1040#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1044pub struct ActionId {
1045 pub kind: ActionKind,
1047 pub subject: Option<String>,
1050}
1051
1052#[must_use]
1055pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
1056 let Some(run) = run else {
1057 let words: Vec<&str> = command
1059 .split_whitespace()
1060 .skip_while(|w| *w == "speccoder")
1061 .take_while(|w| {
1062 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1063 && w.chars()
1064 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1065 })
1066 .collect();
1067 return ActionId {
1068 kind: ActionKind::Command,
1069 subject: (!words.is_empty()).then(|| words.join(" ")),
1070 };
1071 };
1072 let subject = match run {
1073 Runnable::Skill { name, .. } => Some(name.clone()),
1074 Runnable::Run { task, .. } => task.clone(),
1075 Runnable::Approve { task, .. }
1076 | Runnable::Reject { task, .. }
1077 | Runnable::Accept { task }
1078 | Runnable::Skip { task }
1079 | Runnable::Nudge { task }
1080 | Runnable::Done { task }
1081 | Runnable::Hint { task }
1082 | Runnable::Rule { task }
1083 | Runnable::KeepSetAside { task }
1084 | Runnable::DropSetAside { task }
1085 | Runnable::AllowAlways { task, .. }
1086 | Runnable::CodeNudge { task, .. }
1087 | Runnable::AcceptanceAt { task, .. }
1088 | Runnable::RuleScenario { task, .. }
1089 | Runnable::DeclinePromotion { task } => Some(task.clone()),
1090 Runnable::Explore { resume, .. } => resume.clone(),
1091 Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1092 Runnable::OracleTests { req, .. } => Some(req.clone()),
1093 Runnable::CheckStory { story } => Some(story.clone()),
1094 Runnable::HookTrust { name, .. } | Runnable::HookTest { name } => Some(name.clone()),
1095 Runnable::AcceptUntestable { scenario } => Some(scenario.clone()),
1096 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1097 Runnable::Comment { id, .. } => id.clone(),
1098 Runnable::RevisionUndo { turn } => turn.clone(),
1099 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1100 Runnable::DecideBatch { run, .. } => Some(run.clone()),
1101 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1102 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1103 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1104 Runnable::BugFix { slug, issue, .. } => {
1105 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1106 }
1107 Runnable::Review { ids, verdict } => Some(match ids.first() {
1108 Some(id) => format!("{verdict}:{id}"),
1109 None => verdict.clone(),
1110 }),
1111 Runnable::PlanThrough { from, through } => Some(match from {
1112 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1113 PlanFrom::Continue => format!("continue:{}", through.as_str()),
1114 }),
1115 Runnable::NewProject { target, .. } => Some(target.clone()),
1116 Runnable::UndoNewProject { commit } => Some(commit.clone()),
1117 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1118 Runnable::Converge
1119 | Runnable::OpenSample { .. }
1120 | Runnable::SampleChecks
1121 | Runnable::Commit { .. }
1122 | Runnable::QuickChange { .. }
1123 | Runnable::FixSetup
1124 | Runnable::Feature
1125 | Runnable::PullRequest
1126 | Runnable::PresetAdd { .. }
1127 | Runnable::CommentsSend { .. } => None,
1128 };
1129 ActionId {
1130 kind: run.kind(),
1131 subject,
1132 }
1133}
1134
1135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1139#[serde(rename_all = "snake_case")]
1140pub enum SkillRole {
1141 Create,
1143 TowardTasks,
1145 Revise,
1147 Check,
1149 Other,
1151}
1152
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1158#[serde(rename_all = "snake_case")]
1159pub enum Via {
1160 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1162 Terminal,
1163 Web,
1165 Window,
1167 Acp,
1169 Phone,
1171 Ci,
1173 Mcp,
1175}
1176
1177impl Via {
1178 #[must_use]
1180 pub const fn as_str(self) -> &'static str {
1181 match self {
1182 Self::Terminal => "terminal",
1183 Self::Web => "web",
1184 Self::Window => "window",
1185 Self::Acp => "acp",
1186 Self::Phone => "phone",
1187 Self::Ci => "ci",
1188 Self::Mcp => "mcp",
1189 }
1190 }
1191
1192 #[must_use]
1195 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1196 if let Some(client) = recorded.strip_prefix("editor ") {
1197 return Some((Self::Acp, Some(client.to_owned())));
1198 }
1199 let via = match recorded {
1200 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1201 "web" => Self::Web,
1202 "window" => Self::Window,
1203 "acp" | "editor" => Self::Acp,
1204 "phone" => Self::Phone,
1205 "ci" => Self::Ci,
1206 "mcp" => Self::Mcp,
1207 _ => return None,
1208 };
1209 Some((via, None))
1210 }
1211}
1212
1213impl std::fmt::Display for Via {
1214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1215 f.write_str(self.as_str())
1216 }
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1221pub struct Action {
1222 pub group: String,
1224 pub label: String,
1226 pub why: String,
1228 pub command: String,
1230 pub run: Option<Runnable>,
1232 pub step: Option<crate::step::How>,
1235 pub blocked: Option<String>,
1237 pub input: Option<String>,
1239 pub confirm: Option<String>,
1242 pub id: ActionId,
1244 pub role: Option<SkillRole>,
1247}
1248
1249impl Action {
1250 #[must_use]
1252 pub fn new(
1253 group: &str,
1254 label: impl Into<String>,
1255 why: impl Into<String>,
1256 command: impl Into<String>,
1257 ) -> Self {
1258 let command = command.into();
1259 Self {
1260 group: group.to_owned(),
1261 label: label.into(),
1262 why: why.into(),
1263 id: identity(None, &command),
1264 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1265 command,
1266 run: None,
1267 blocked: None,
1268 input: None,
1269 confirm: None,
1270 role: None,
1271 }
1272 }
1273
1274 #[must_use]
1276 pub fn with_run(mut self, run: Runnable) -> Self {
1277 self.id = identity(Some(&run), &self.command);
1278 self.run = Some(run);
1279 self.step = None;
1280 self
1281 }
1282
1283 #[must_use]
1285 pub const fn with_role(mut self, role: SkillRole) -> Self {
1286 self.role = Some(role);
1287 self
1288 }
1289
1290 #[must_use]
1292 pub const fn runnable(&self) -> bool {
1293 self.run.is_some() && self.blocked.is_none()
1294 }
1295}
1296
1297#[must_use]
1299pub fn primary(actions: &[Action]) -> Option<&Action> {
1300 actions.iter().find(|a| a.group == "next" && a.runnable())
1301}
1302
1303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1305#[serde(rename_all = "snake_case")]
1306pub enum StepState {
1307 Done,
1309 Now,
1311 Todo,
1313 Skipped,
1316}
1317
1318#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1321pub struct JourneyStep {
1322 pub name: String,
1324 pub state: StepState,
1326}
1327
1328#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1330pub struct JourneyFacts {
1331 pub spec: bool,
1333 pub plan: bool,
1335 pub tasks: bool,
1337 pub uncommitted: bool,
1339 pub total: usize,
1341 pub finished: usize,
1343}
1344
1345#[must_use]
1349pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1350 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1351 let done = [
1352 ("spec", f.spec),
1353 ("plan", f.plan),
1354 ("tasks", f.tasks && f.total > 0),
1356 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1357 ("run", f.total > 0 && f.finished == f.total),
1358 ("review", false),
1359 ];
1360 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1361 let now = done
1362 .iter()
1363 .enumerate()
1364 .position(|(i, (_, d))| !d && !passed(i))
1365 .unwrap_or(done.len());
1366 done.iter()
1367 .enumerate()
1368 .map(|(i, (name, d))| JourneyStep {
1369 name: (*name).to_owned(),
1370 state: if *d && i < now {
1371 StepState::Done
1372 } else if passed(i) && i < now {
1373 StepState::Skipped
1374 } else if i == now {
1375 StepState::Now
1376 } else {
1377 StepState::Todo
1378 },
1379 })
1380 .collect()
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386
1387 #[test]
1388 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1389 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1391 .unwrap_or_else(|e| panic!("{e}"));
1392 let mut named: Vec<String> = schema["oneOf"]
1393 .as_array()
1394 .into_iter()
1395 .flatten()
1396 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1397 .collect();
1398 named.sort();
1399 let mut listed: Vec<String> = ActionKind::ALL
1400 .iter()
1401 .map(|k| k.as_str().to_owned())
1402 .collect();
1403 listed.sort();
1404 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1405 for k in ActionKind::ALL {
1406 assert_eq!(
1407 serde_json::to_value(k).unwrap_or_default(),
1408 k.as_str(),
1409 "{k:?}"
1410 );
1411 if let Some(run) = k.example() {
1412 assert_eq!(run.kind(), *k);
1413 assert_eq!(identity(Some(&run), "").kind, *k);
1414 }
1415 }
1416 assert_eq!(
1417 Runnable::examples().len(),
1418 ActionKind::ALL.len() - 1,
1419 "every kind but `command` has a runnable"
1420 );
1421 }
1422
1423 #[test]
1424 fn identity_is_what_runs_never_how_it_is_worded() {
1425 let a = Action::new(
1426 "task",
1427 "accept T004",
1428 "tick it",
1429 "speccoder task accept T004",
1430 )
1431 .with_run(Runnable::Accept {
1432 task: "T004".to_owned(),
1433 });
1434 let mut b = a.clone();
1435 b.label = "x tick it anyway".to_owned();
1436 b.why = "x".to_owned();
1437 assert_eq!(a.id, b.id);
1438 assert_eq!(
1439 a.id,
1440 ActionId {
1441 kind: ActionKind::Accept,
1442 subject: Some("T004".to_owned())
1443 }
1444 );
1445 let id = |c: &str| identity(None, c).subject;
1446 assert_eq!(
1447 id("speccoder attest verify").as_deref(),
1448 Some("attest verify")
1449 );
1450 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1451 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1452 assert_eq!(
1453 id("speccoder spec specify \"…\"").as_deref(),
1454 Some("spec specify")
1455 );
1456 let review = |ids: &[&str], v: &str| {
1457 identity(
1458 Some(&Runnable::Review {
1459 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1460 verdict: v.to_owned(),
1461 }),
1462 "",
1463 )
1464 .subject
1465 };
1466 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1467 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1468 }
1469
1470 #[test]
1471 fn origins_read_back_from_every_value_ever_recorded() {
1472 for (old, via, client) in [
1473 ("cli", Via::Terminal, None),
1474 ("tui", Via::Terminal, None),
1475 ("dashboard", Via::Terminal, None),
1476 ("terminal", Via::Terminal, None),
1477 ("web", Via::Web, None),
1478 ("editor Zed", Via::Acp, Some("Zed")),
1479 ] {
1480 assert_eq!(
1481 Via::read(old),
1482 Some((via, client.map(str::to_owned))),
1483 "{old}"
1484 );
1485 }
1486 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1487 assert_eq!(parsed, Via::Terminal);
1488 assert_eq!(Via::Acp.as_str(), "acp");
1489 assert_eq!(Via::read("nobody"), None);
1490 }
1491
1492 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1493 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1494 }
1495
1496 #[test]
1497 fn the_journey_marks_what_is_done_and_what_is_now() {
1498 use StepState::{Done, Now, Todo};
1499 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1500 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1501 assert_eq!(
1502 states(JourneyFacts::default()),
1503 at([Now, Todo, Todo, Todo, Todo, Todo])
1504 );
1505 let planned = JourneyFacts {
1506 spec: true,
1507 plan: true,
1508 ..JourneyFacts::default()
1509 };
1510 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1511 let written = JourneyFacts {
1512 tasks: true,
1513 uncommitted: true,
1514 total: 3,
1515 ..planned
1516 };
1517 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1518 let running = JourneyFacts {
1519 uncommitted: false,
1520 finished: 1,
1521 ..written
1522 };
1523 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1524 let finished = JourneyFacts {
1525 finished: 3,
1526 ..running
1527 };
1528 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1529 let empty = JourneyFacts {
1531 tasks: true,
1532 ..planned
1533 };
1534 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1535 }
1536
1537 #[test]
1538 fn a_task_list_without_a_spec_skips_to_the_run() {
1539 use StepState::{Now, Skipped, 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 let only_tasks = JourneyFacts {
1543 tasks: true,
1544 total: 4,
1545 finished: 1,
1546 ..JourneyFacts::default()
1547 };
1548 assert_eq!(
1549 states(only_tasks),
1550 at([
1551 Skipped,
1552 Skipped,
1553 StepState::Done,
1554 StepState::Done,
1555 Now,
1556 Todo
1557 ])
1558 );
1559 let planned = JourneyFacts {
1561 plan: true,
1562 ..JourneyFacts::default()
1563 };
1564 assert_eq!(
1565 states(planned),
1566 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1567 );
1568 }
1569}