1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum Runnable {
12 Skill {
14 name: String,
16 arguments: String,
18 },
19 Run {
21 task: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 agent: Option<String>,
27 },
28 Converge,
31 Commit {
33 paths: Vec<String>,
35 },
36 Approve {
39 task: String,
41 call: String,
43 digest: String,
45 },
46 Reject {
48 task: String,
50 call: String,
52 digest: String,
54 },
55 Accept {
57 task: String,
59 },
60 Skip {
62 task: String,
64 },
65 Nudge {
67 task: String,
69 },
70 Done {
73 task: String,
75 },
76 Hint {
78 task: String,
80 },
81 Rule {
83 task: String,
85 },
86 Review {
89 ids: Vec<String>,
91 verdict: String,
95 },
96 IssueComment {
98 issue: u64,
100 assign: bool,
102 },
103 QuickChange {
105 request: String,
107 checks: Vec<String>,
109 run: bool,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 asked: Option<String>,
114 },
115 KeepSetAside {
118 task: String,
120 },
121 DropSetAside {
123 task: String,
125 },
126 FixSetup,
129 Feature,
131 PullRequest,
133 BugFix {
136 symptom: String,
138 expected: Vec<String>,
140 unchanged: Vec<String>,
142 issue: Option<u64>,
144 tests: Vec<String>,
147 candidate: Option<String>,
149 slug: Option<String>,
151 run: bool,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 asked: Option<String>,
156 },
157 BugConfirm {
160 task: String,
162 digest: String,
164 reject: bool,
166 },
167 BugKeep {
170 task: String,
172 },
173 BugRun {
175 slug: Option<String>,
177 task: Option<String>,
179 },
180 AllowAlways {
183 task: String,
185 call: String,
187 },
188 WidenPolicy {
191 key: PolicyList,
193 prefix: Vec<String>,
195 digest: String,
197 },
198 DecideBatch {
200 run: String,
202 verdict: BatchVerdict,
204 calls: Vec<String>,
206 digest: String,
208 resume_asking: bool,
210 },
211 OracleTests {
214 req: String,
216 op: crate::oracle::OracleOp,
218 path: Option<String>,
220 scenario: Option<String>,
222 reading: Option<crate::oracle::Reading>,
224 },
225 ApproveAndRun {
228 mode: crate::oracle::RunMode,
230 grants: Vec<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 sheet: Option<String>,
236 },
237 Comment {
239 op: CommentOp,
241 anchor: Option<crate::oracle::CommentAnchor>,
243 id: Option<String>,
245 },
246 CommentsSend {
248 ids: Vec<String>,
251 },
252 RevisionUndo {
254 turn: Option<String>,
256 },
257 CodeNudge {
259 task: String,
261 comments: Vec<crate::oracle::CodeComment>,
263 },
264 AcceptanceAt {
268 feature: String,
270 story: String,
272 task: String,
274 revoke: bool,
276 },
277 DeferAcceptance {
281 task: String,
283 revoke: bool,
285 },
286 PresetAdd {
289 update: bool,
291 },
292 PlanThrough {
296 from: PlanFrom,
298 through: Through,
300 },
301 NewProject {
304 target: String,
306 choices: crate::new_project::NewChoices,
308 plan_digest: String,
310 },
311 UndoNewProject {
314 commit: String,
316 },
317 CloneRepository {
319 repo: String,
321 target: String,
323 },
324 OpenSample {
327 fresh: bool,
329 },
330 SampleChecks,
333 DeclinePromotion {
336 task: String,
338 },
339 Explore {
342 idea: String,
344 #[serde(default)]
346 skill: Option<String>,
347 #[serde(default)]
349 resume: Option<String>,
350 },
351 ExploreChoose {
354 session: String,
356 approach: usize,
358 #[serde(default)]
360 constraints: Vec<String>,
361 #[serde(default)]
363 questions: Vec<String>,
364 #[serde(default)]
366 flag: bool,
367 },
368}
369
370#[derive(
372 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
373)]
374#[serde(rename_all = "snake_case")]
375pub enum Through {
376 Spec,
378 Plan,
380 Tasks,
382 Analyze,
384}
385
386impl Through {
387 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
389
390 #[must_use]
392 pub const fn as_str(self) -> &'static str {
393 match self {
394 Self::Spec => "spec",
395 Self::Plan => "plan",
396 Self::Tasks => "tasks",
397 Self::Analyze => "analyze",
398 }
399 }
400
401 #[must_use]
403 pub fn parse(s: &str) -> Option<Self> {
404 Self::ALL.into_iter().find(|t| t.as_str() == s)
405 }
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
411#[serde(tag = "kind", rename_all = "snake_case")]
412pub enum FeatureSource {
413 Description,
415 Issue {
417 number: u64,
419 },
420 Promotion {
423 task: String,
425 #[serde(default)]
427 conversations: Vec<String>,
428 },
429 Explore {
431 session: String,
433 digest: String,
435 },
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
440#[serde(tag = "kind", rename_all = "snake_case")]
441pub enum PlanFrom {
442 Create {
444 description: String,
446 source: FeatureSource,
448 },
449 Continue,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
455#[serde(rename_all = "snake_case")]
456pub enum CommentOp {
457 Add,
459 Delete,
461 Keep,
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
467#[serde(rename_all = "snake_case")]
468pub enum PolicyList {
469 CommandsPrefix,
471 NetworkPrefix,
473}
474
475impl PolicyList {
476 #[must_use]
478 pub const fn as_str(self) -> &'static str {
479 match self {
480 Self::CommandsPrefix => "commands_prefix",
481 Self::NetworkPrefix => "network_prefix",
482 }
483 }
484
485 #[must_use]
487 pub const fn key(self) -> &'static str {
488 match self {
489 Self::CommandsPrefix => "commands.allow_prefix",
490 Self::NetworkPrefix => "sandbox.network_allow_prefix",
491 }
492 }
493
494 #[must_use]
496 pub fn of_key(key: &str) -> Option<Self> {
497 match key {
498 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
499 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
500 _ => None,
501 }
502 }
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
507#[serde(rename_all = "snake_case")]
508pub enum BatchVerdict {
509 Approve,
511 Reject,
513}
514
515#[derive(
521 Debug,
522 Clone,
523 Copy,
524 PartialEq,
525 Eq,
526 Hash,
527 PartialOrd,
528 Ord,
529 Serialize,
530 Deserialize,
531 schemars::JsonSchema,
532)]
533#[serde(rename_all = "snake_case")]
534pub enum ActionKind {
535 Skill,
537 RunTask,
539 RunFeature,
541 Converge,
543 Commit,
545 Approve,
547 Reject,
549 Accept,
551 Skip,
553 Nudge,
555 Done,
557 Hint,
559 Rule,
561 Review,
563 IssueComment,
565 QuickChange,
567 KeepSetAside,
569 DropSetAside,
571 FixSetup,
573 Feature,
575 PullRequest,
577 BugFix,
579 BugConfirm,
581 BugRun,
583 BugKeep,
585 AllowAlways,
587 WidenPolicy,
589 DecideBatch,
591 OracleTests,
593 ApproveAndRun,
595 Comment,
597 CommentsSend,
599 RevisionUndo,
601 CodeNudge,
603 AcceptanceAt,
605 DeferAcceptance,
607 PresetAdd,
609 PlanThrough,
611 NewProject,
613 UndoNewProject,
615 CloneRepository,
617 OpenSample,
619 SampleChecks,
621 DeclinePromotion,
623 Explore,
625 ExploreChoose,
627 Command,
629}
630
631impl ActionKind {
632 pub const ALL: &'static [Self] = &[
634 Self::Skill,
635 Self::RunTask,
636 Self::RunFeature,
637 Self::Converge,
638 Self::Commit,
639 Self::Approve,
640 Self::Reject,
641 Self::Accept,
642 Self::Skip,
643 Self::Nudge,
644 Self::Done,
645 Self::Hint,
646 Self::Rule,
647 Self::Review,
648 Self::IssueComment,
649 Self::QuickChange,
650 Self::KeepSetAside,
651 Self::DropSetAside,
652 Self::FixSetup,
653 Self::Feature,
654 Self::PullRequest,
655 Self::BugFix,
656 Self::BugConfirm,
657 Self::BugRun,
658 Self::BugKeep,
659 Self::AllowAlways,
660 Self::WidenPolicy,
661 Self::DecideBatch,
662 Self::OracleTests,
663 Self::ApproveAndRun,
664 Self::Comment,
665 Self::CommentsSend,
666 Self::RevisionUndo,
667 Self::CodeNudge,
668 Self::AcceptanceAt,
669 Self::DeferAcceptance,
670 Self::PresetAdd,
671 Self::PlanThrough,
672 Self::NewProject,
673 Self::UndoNewProject,
674 Self::CloneRepository,
675 Self::OpenSample,
676 Self::SampleChecks,
677 Self::DeclinePromotion,
678 Self::Explore,
679 Self::ExploreChoose,
680 Self::Command,
681 ];
682
683 #[must_use]
685 pub const fn as_str(self) -> &'static str {
686 match self {
687 Self::Skill => "skill",
688 Self::RunTask => "run_task",
689 Self::RunFeature => "run_feature",
690 Self::Converge => "converge",
691 Self::Commit => "commit",
692 Self::Approve => "approve",
693 Self::Reject => "reject",
694 Self::Accept => "accept",
695 Self::Skip => "skip",
696 Self::Nudge => "nudge",
697 Self::Done => "done",
698 Self::Hint => "hint",
699 Self::Rule => "rule",
700 Self::Review => "review",
701 Self::IssueComment => "issue_comment",
702 Self::QuickChange => "quick_change",
703 Self::KeepSetAside => "keep_set_aside",
704 Self::DropSetAside => "drop_set_aside",
705 Self::FixSetup => "fix_setup",
706 Self::Feature => "feature",
707 Self::PullRequest => "pull_request",
708 Self::BugFix => "bug_fix",
709 Self::BugConfirm => "bug_confirm",
710 Self::BugRun => "bug_run",
711 Self::BugKeep => "bug_keep",
712 Self::AllowAlways => "allow_always",
713 Self::WidenPolicy => "widen_policy",
714 Self::DecideBatch => "decide_batch",
715 Self::OracleTests => "oracle_tests",
716 Self::ApproveAndRun => "approve_and_run",
717 Self::Comment => "comment",
718 Self::CommentsSend => "comments_send",
719 Self::RevisionUndo => "revision_undo",
720 Self::CodeNudge => "code_nudge",
721 Self::AcceptanceAt => "acceptance_at",
722 Self::DeferAcceptance => "defer_acceptance",
723 Self::PresetAdd => "preset_add",
724 Self::PlanThrough => "plan_through",
725 Self::NewProject => "new_project",
726 Self::UndoNewProject => "undo_new_project",
727 Self::CloneRepository => "clone_repository",
728 Self::OpenSample => "open_sample",
729 Self::SampleChecks => "sample_checks",
730 Self::DeclinePromotion => "decline_promotion",
731 Self::Explore => "explore",
732 Self::ExploreChoose => "explore_choose",
733 Self::Command => "command",
734 }
735 }
736
737 #[must_use]
740 pub fn example(self) -> Option<Runnable> {
741 let task = || "T001".to_owned();
742 Some(match self {
743 Self::Skill => Runnable::Skill {
744 name: "plan".to_owned(),
745 arguments: String::new(),
746 },
747 Self::RunTask => Runnable::Run {
748 task: Some(task()),
749 agent: None,
750 },
751 Self::RunFeature => Runnable::Run {
752 task: None,
753 agent: None,
754 },
755 Self::Converge => Runnable::Converge,
756 Self::Commit => Runnable::Commit {
757 paths: vec!["plan.md".to_owned()],
758 },
759 Self::Approve => Runnable::Approve {
760 task: task(),
761 call: "0f3c".to_owned(),
762 digest: "d1".to_owned(),
763 },
764 Self::Reject => Runnable::Reject {
765 task: task(),
766 call: "0f3c".to_owned(),
767 digest: "d1".to_owned(),
768 },
769 Self::Accept => Runnable::Accept { task: task() },
770 Self::Skip => Runnable::Skip { task: task() },
771 Self::Nudge => Runnable::Nudge { task: task() },
772 Self::Done => Runnable::Done { task: task() },
773 Self::Hint => Runnable::Hint { task: task() },
774 Self::Rule => Runnable::Rule { task: task() },
775 Self::Review => Runnable::Review {
776 ids: vec!["FR-001".to_owned()],
777 verdict: "approve".to_owned(),
778 },
779 Self::IssueComment => Runnable::IssueComment {
780 issue: 42,
781 assign: false,
782 },
783 Self::QuickChange => Runnable::QuickChange {
784 request: "a change".to_owned(),
785 checks: Vec::new(),
786 run: false,
787 asked: None,
788 },
789 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
790 Self::DropSetAside => Runnable::DropSetAside { task: task() },
791 Self::FixSetup => Runnable::FixSetup,
792 Self::Feature => Runnable::Feature,
793 Self::PullRequest => Runnable::PullRequest,
794 Self::BugFix => Runnable::BugFix {
795 symptom: "saving an empty title crashes".to_owned(),
796 expected: vec!["an empty title is refused".to_owned()],
797 unchanged: Vec::new(),
798 issue: None,
799 tests: Vec::new(),
800 candidate: None,
801 slug: None,
802 run: false,
803 asked: None,
804 },
805 Self::BugConfirm => Runnable::BugConfirm {
806 task: "007-bugfixes/T001".to_owned(),
807 digest: "sha256:0".to_owned(),
808 reject: false,
809 },
810 Self::BugRun => Runnable::BugRun {
811 slug: None,
812 task: Some("007-bugfixes/T001".to_owned()),
813 },
814 Self::BugKeep => Runnable::BugKeep {
815 task: "007-bugfixes/T001".to_owned(),
816 },
817 Self::AllowAlways => Runnable::AllowAlways {
818 task: task(),
819 call: "0f3c".to_owned(),
820 },
821 Self::WidenPolicy => Runnable::WidenPolicy {
822 key: PolicyList::CommandsPrefix,
823 prefix: vec!["npm".to_owned(), "run".to_owned()],
824 digest: "d1".to_owned(),
825 },
826 Self::DecideBatch => Runnable::DecideBatch {
827 run: "run-1-0a0b0c0d".to_owned(),
828 verdict: BatchVerdict::Approve,
829 calls: vec!["0f3c".to_owned()],
830 digest: "d1".to_owned(),
831 resume_asking: false,
832 },
833 Self::OracleTests => Runnable::OracleTests {
834 req: "US1".to_owned(),
835 op: crate::oracle::OracleOp::Write,
836 path: None,
837 scenario: None,
838 reading: None,
839 },
840 Self::ApproveAndRun => Runnable::ApproveAndRun {
841 mode: crate::oracle::RunMode::Ask,
842 grants: Vec::new(),
843 sheet: None,
844 },
845 Self::Comment => Runnable::Comment {
846 op: CommentOp::Delete,
847 anchor: None,
848 id: Some("c1".to_owned()),
849 },
850 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
851 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
852 Self::CodeNudge => Runnable::CodeNudge {
853 task: task(),
854 comments: vec![crate::oracle::CodeComment {
855 path: "src/lib.rs".to_owned(),
856 line: 1,
857 quote: "pub fn ok() {}".to_owned(),
858 text: "name it".to_owned(),
859 }],
860 },
861 Self::AcceptanceAt => Runnable::AcceptanceAt {
862 feature: "001-greet-function".to_owned(),
863 story: "US1".to_owned(),
864 task: "T003".to_owned(),
865 revoke: false,
866 },
867 Self::DeferAcceptance => Runnable::DeferAcceptance {
868 task: "T018".to_owned(),
869 revoke: false,
870 },
871 Self::PresetAdd => Runnable::PresetAdd { update: false },
872 Self::PlanThrough => Runnable::PlanThrough {
873 from: PlanFrom::Create {
874 description: "Users can export a report as CSV".to_owned(),
875 source: FeatureSource::Description,
876 },
877 through: Through::Tasks,
878 },
879 Self::NewProject => Runnable::NewProject {
880 target: "/tmp/md2csv".to_owned(),
881 choices: crate::new_project::NewChoices {
882 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
883 ..crate::new_project::NewChoices::default()
884 },
885 plan_digest: "sha256:0".to_owned(),
886 },
887 Self::UndoNewProject => Runnable::UndoNewProject {
888 commit: "1a2b3c4".to_owned(),
889 },
890 Self::CloneRepository => Runnable::CloneRepository {
891 repo: "octo/hello".to_owned(),
892 target: "/tmp/hello".to_owned(),
893 },
894 Self::OpenSample => Runnable::OpenSample { fresh: false },
895 Self::SampleChecks => Runnable::SampleChecks,
896 Self::DeclinePromotion => Runnable::DeclinePromotion {
897 task: "004-quick-changes/T002".to_owned(),
898 },
899 Self::Explore => Runnable::Explore {
900 idea: "export reports as CSV".to_owned(),
901 skill: None,
902 resume: None,
903 },
904 Self::ExploreChoose => Runnable::ExploreChoose {
905 session: "ex-1a2b3c4d5e6f".to_owned(),
906 approach: 2,
907 constraints: vec!["no new dependency".to_owned()],
908 questions: Vec::new(),
909 flag: false,
910 },
911 Self::Command => return None,
912 })
913 }
914}
915
916impl Runnable {
917 #[must_use]
919 pub fn examples() -> Vec<Self> {
920 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
921 }
922
923 #[must_use]
925 pub const fn kind(&self) -> ActionKind {
926 match self {
927 Self::Skill { .. } => ActionKind::Skill,
928 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
929 Self::Run { task: None, .. } => ActionKind::RunFeature,
930 Self::Converge => ActionKind::Converge,
931 Self::Commit { .. } => ActionKind::Commit,
932 Self::Approve { .. } => ActionKind::Approve,
933 Self::Reject { .. } => ActionKind::Reject,
934 Self::Accept { .. } => ActionKind::Accept,
935 Self::Skip { .. } => ActionKind::Skip,
936 Self::Nudge { .. } => ActionKind::Nudge,
937 Self::Done { .. } => ActionKind::Done,
938 Self::Hint { .. } => ActionKind::Hint,
939 Self::Rule { .. } => ActionKind::Rule,
940 Self::Review { .. } => ActionKind::Review,
941 Self::IssueComment { .. } => ActionKind::IssueComment,
942 Self::QuickChange { .. } => ActionKind::QuickChange,
943 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
944 Self::DropSetAside { .. } => ActionKind::DropSetAside,
945 Self::FixSetup => ActionKind::FixSetup,
946 Self::Feature => ActionKind::Feature,
947 Self::PullRequest => ActionKind::PullRequest,
948 Self::BugFix { .. } => ActionKind::BugFix,
949 Self::BugConfirm { .. } => ActionKind::BugConfirm,
950 Self::BugRun { .. } => ActionKind::BugRun,
951 Self::BugKeep { .. } => ActionKind::BugKeep,
952 Self::AllowAlways { .. } => ActionKind::AllowAlways,
953 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
954 Self::DecideBatch { .. } => ActionKind::DecideBatch,
955 Self::OracleTests { .. } => ActionKind::OracleTests,
956 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
957 Self::Comment { .. } => ActionKind::Comment,
958 Self::CommentsSend { .. } => ActionKind::CommentsSend,
959 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
960 Self::CodeNudge { .. } => ActionKind::CodeNudge,
961 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
962 Self::DeferAcceptance { .. } => ActionKind::DeferAcceptance,
963 Self::PresetAdd { .. } => ActionKind::PresetAdd,
964 Self::PlanThrough { .. } => ActionKind::PlanThrough,
965 Self::NewProject { .. } => ActionKind::NewProject,
966 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
967 Self::CloneRepository { .. } => ActionKind::CloneRepository,
968 Self::OpenSample { .. } => ActionKind::OpenSample,
969 Self::SampleChecks => ActionKind::SampleChecks,
970 Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
971 Self::Explore { .. } => ActionKind::Explore,
972 Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
973 }
974 }
975}
976
977#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
981pub struct ActionId {
982 pub kind: ActionKind,
984 pub subject: Option<String>,
987}
988
989#[must_use]
992pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
993 let Some(run) = run else {
994 let words: Vec<&str> = command
996 .split_whitespace()
997 .skip_while(|w| *w == "speccoder")
998 .take_while(|w| {
999 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1000 && w.chars()
1001 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1002 })
1003 .collect();
1004 return ActionId {
1005 kind: ActionKind::Command,
1006 subject: (!words.is_empty()).then(|| words.join(" ")),
1007 };
1008 };
1009 let subject = match run {
1010 Runnable::Skill { name, .. } => Some(name.clone()),
1011 Runnable::Run { task, .. } => task.clone(),
1012 Runnable::Approve { task, .. }
1013 | Runnable::Reject { task, .. }
1014 | Runnable::Accept { task }
1015 | Runnable::Skip { task }
1016 | Runnable::Nudge { task }
1017 | Runnable::Done { task }
1018 | Runnable::Hint { task }
1019 | Runnable::Rule { task }
1020 | Runnable::KeepSetAside { task }
1021 | Runnable::DropSetAside { task }
1022 | Runnable::AllowAlways { task, .. }
1023 | Runnable::CodeNudge { task, .. }
1024 | Runnable::AcceptanceAt { task, .. }
1025 | Runnable::DeferAcceptance { task, .. }
1026 | Runnable::DeclinePromotion { task } => Some(task.clone()),
1027 Runnable::Explore { resume, .. } => resume.clone(),
1028 Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1029 Runnable::OracleTests { req, .. } => Some(req.clone()),
1030 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1031 Runnable::Comment { id, .. } => id.clone(),
1032 Runnable::RevisionUndo { turn } => turn.clone(),
1033 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1034 Runnable::DecideBatch { run, .. } => Some(run.clone()),
1035 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1036 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1037 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1038 Runnable::BugFix { slug, issue, .. } => {
1039 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1040 }
1041 Runnable::Review { ids, verdict } => Some(match ids.first() {
1042 Some(id) => format!("{verdict}:{id}"),
1043 None => verdict.clone(),
1044 }),
1045 Runnable::PlanThrough { from, through } => Some(match from {
1046 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1047 PlanFrom::Continue => format!("continue:{}", through.as_str()),
1048 }),
1049 Runnable::NewProject { target, .. } => Some(target.clone()),
1050 Runnable::UndoNewProject { commit } => Some(commit.clone()),
1051 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1052 Runnable::Converge
1053 | Runnable::OpenSample { .. }
1054 | Runnable::SampleChecks
1055 | Runnable::Commit { .. }
1056 | Runnable::QuickChange { .. }
1057 | Runnable::FixSetup
1058 | Runnable::Feature
1059 | Runnable::PullRequest
1060 | Runnable::PresetAdd { .. }
1061 | Runnable::CommentsSend { .. } => None,
1062 };
1063 ActionId {
1064 kind: run.kind(),
1065 subject,
1066 }
1067}
1068
1069#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1073#[serde(rename_all = "snake_case")]
1074pub enum SkillRole {
1075 Create,
1077 TowardTasks,
1079 Revise,
1081 Check,
1083 Other,
1085}
1086
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1092#[serde(rename_all = "snake_case")]
1093pub enum Via {
1094 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1096 Terminal,
1097 Web,
1099 Window,
1101 Acp,
1103 Phone,
1105 Ci,
1107 Mcp,
1109}
1110
1111impl Via {
1112 #[must_use]
1114 pub const fn as_str(self) -> &'static str {
1115 match self {
1116 Self::Terminal => "terminal",
1117 Self::Web => "web",
1118 Self::Window => "window",
1119 Self::Acp => "acp",
1120 Self::Phone => "phone",
1121 Self::Ci => "ci",
1122 Self::Mcp => "mcp",
1123 }
1124 }
1125
1126 #[must_use]
1129 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1130 if let Some(client) = recorded.strip_prefix("editor ") {
1131 return Some((Self::Acp, Some(client.to_owned())));
1132 }
1133 let via = match recorded {
1134 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1135 "web" => Self::Web,
1136 "window" => Self::Window,
1137 "acp" | "editor" => Self::Acp,
1138 "phone" => Self::Phone,
1139 "ci" => Self::Ci,
1140 "mcp" => Self::Mcp,
1141 _ => return None,
1142 };
1143 Some((via, None))
1144 }
1145}
1146
1147impl std::fmt::Display for Via {
1148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1149 f.write_str(self.as_str())
1150 }
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1155pub struct Action {
1156 pub group: String,
1158 pub label: String,
1160 pub why: String,
1162 pub command: String,
1164 pub run: Option<Runnable>,
1166 pub step: Option<crate::step::How>,
1169 pub blocked: Option<String>,
1171 pub input: Option<String>,
1173 pub confirm: Option<String>,
1176 pub id: ActionId,
1178 pub role: Option<SkillRole>,
1181}
1182
1183impl Action {
1184 #[must_use]
1186 pub fn new(
1187 group: &str,
1188 label: impl Into<String>,
1189 why: impl Into<String>,
1190 command: impl Into<String>,
1191 ) -> Self {
1192 let command = command.into();
1193 Self {
1194 group: group.to_owned(),
1195 label: label.into(),
1196 why: why.into(),
1197 id: identity(None, &command),
1198 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1199 command,
1200 run: None,
1201 blocked: None,
1202 input: None,
1203 confirm: None,
1204 role: None,
1205 }
1206 }
1207
1208 #[must_use]
1210 pub fn with_run(mut self, run: Runnable) -> Self {
1211 self.id = identity(Some(&run), &self.command);
1212 self.run = Some(run);
1213 self.step = None;
1214 self
1215 }
1216
1217 #[must_use]
1219 pub const fn with_role(mut self, role: SkillRole) -> Self {
1220 self.role = Some(role);
1221 self
1222 }
1223
1224 #[must_use]
1226 pub const fn runnable(&self) -> bool {
1227 self.run.is_some() && self.blocked.is_none()
1228 }
1229}
1230
1231#[must_use]
1233pub fn primary(actions: &[Action]) -> Option<&Action> {
1234 actions.iter().find(|a| a.group == "next" && a.runnable())
1235}
1236
1237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1239#[serde(rename_all = "snake_case")]
1240pub enum StepState {
1241 Done,
1243 Now,
1245 Todo,
1247 Skipped,
1250}
1251
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1255pub struct JourneyStep {
1256 pub name: String,
1258 pub state: StepState,
1260}
1261
1262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1264pub struct JourneyFacts {
1265 pub spec: bool,
1267 pub plan: bool,
1269 pub tasks: bool,
1271 pub uncommitted: bool,
1273 pub total: usize,
1275 pub finished: usize,
1277}
1278
1279#[must_use]
1283pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1284 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1285 let done = [
1286 ("spec", f.spec),
1287 ("plan", f.plan),
1288 ("tasks", f.tasks && f.total > 0),
1290 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1291 ("run", f.total > 0 && f.finished == f.total),
1292 ("review", false),
1293 ];
1294 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1295 let now = done
1296 .iter()
1297 .enumerate()
1298 .position(|(i, (_, d))| !d && !passed(i))
1299 .unwrap_or(done.len());
1300 done.iter()
1301 .enumerate()
1302 .map(|(i, (name, d))| JourneyStep {
1303 name: (*name).to_owned(),
1304 state: if *d && i < now {
1305 StepState::Done
1306 } else if passed(i) && i < now {
1307 StepState::Skipped
1308 } else if i == now {
1309 StepState::Now
1310 } else {
1311 StepState::Todo
1312 },
1313 })
1314 .collect()
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319 use super::*;
1320
1321 #[test]
1322 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1323 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1325 .unwrap_or_else(|e| panic!("{e}"));
1326 let mut named: Vec<String> = schema["oneOf"]
1327 .as_array()
1328 .into_iter()
1329 .flatten()
1330 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1331 .collect();
1332 named.sort();
1333 let mut listed: Vec<String> = ActionKind::ALL
1334 .iter()
1335 .map(|k| k.as_str().to_owned())
1336 .collect();
1337 listed.sort();
1338 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1339 for k in ActionKind::ALL {
1340 assert_eq!(
1341 serde_json::to_value(k).unwrap_or_default(),
1342 k.as_str(),
1343 "{k:?}"
1344 );
1345 if let Some(run) = k.example() {
1346 assert_eq!(run.kind(), *k);
1347 assert_eq!(identity(Some(&run), "").kind, *k);
1348 }
1349 }
1350 assert_eq!(
1351 Runnable::examples().len(),
1352 ActionKind::ALL.len() - 1,
1353 "every kind but `command` has a runnable"
1354 );
1355 }
1356
1357 #[test]
1358 fn identity_is_what_runs_never_how_it_is_worded() {
1359 let a = Action::new(
1360 "task",
1361 "accept T004",
1362 "tick it",
1363 "speccoder task accept T004",
1364 )
1365 .with_run(Runnable::Accept {
1366 task: "T004".to_owned(),
1367 });
1368 let mut b = a.clone();
1369 b.label = "x tick it anyway".to_owned();
1370 b.why = "x".to_owned();
1371 assert_eq!(a.id, b.id);
1372 assert_eq!(
1373 a.id,
1374 ActionId {
1375 kind: ActionKind::Accept,
1376 subject: Some("T004".to_owned())
1377 }
1378 );
1379 let id = |c: &str| identity(None, c).subject;
1380 assert_eq!(
1381 id("speccoder attest verify").as_deref(),
1382 Some("attest verify")
1383 );
1384 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1385 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1386 assert_eq!(
1387 id("speccoder spec specify \"…\"").as_deref(),
1388 Some("spec specify")
1389 );
1390 let review = |ids: &[&str], v: &str| {
1391 identity(
1392 Some(&Runnable::Review {
1393 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1394 verdict: v.to_owned(),
1395 }),
1396 "",
1397 )
1398 .subject
1399 };
1400 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1401 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1402 }
1403
1404 #[test]
1405 fn origins_read_back_from_every_value_ever_recorded() {
1406 for (old, via, client) in [
1407 ("cli", Via::Terminal, None),
1408 ("tui", Via::Terminal, None),
1409 ("dashboard", Via::Terminal, None),
1410 ("terminal", Via::Terminal, None),
1411 ("web", Via::Web, None),
1412 ("editor Zed", Via::Acp, Some("Zed")),
1413 ] {
1414 assert_eq!(
1415 Via::read(old),
1416 Some((via, client.map(str::to_owned))),
1417 "{old}"
1418 );
1419 }
1420 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1421 assert_eq!(parsed, Via::Terminal);
1422 assert_eq!(Via::Acp.as_str(), "acp");
1423 assert_eq!(Via::read("nobody"), None);
1424 }
1425
1426 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1427 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1428 }
1429
1430 #[test]
1431 fn the_journey_marks_what_is_done_and_what_is_now() {
1432 use StepState::{Done, Now, Todo};
1433 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1434 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1435 assert_eq!(
1436 states(JourneyFacts::default()),
1437 at([Now, Todo, Todo, Todo, Todo, Todo])
1438 );
1439 let planned = JourneyFacts {
1440 spec: true,
1441 plan: true,
1442 ..JourneyFacts::default()
1443 };
1444 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1445 let written = JourneyFacts {
1446 tasks: true,
1447 uncommitted: true,
1448 total: 3,
1449 ..planned
1450 };
1451 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1452 let running = JourneyFacts {
1453 uncommitted: false,
1454 finished: 1,
1455 ..written
1456 };
1457 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1458 let finished = JourneyFacts {
1459 finished: 3,
1460 ..running
1461 };
1462 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1463 let empty = JourneyFacts {
1465 tasks: true,
1466 ..planned
1467 };
1468 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1469 }
1470
1471 #[test]
1472 fn a_task_list_without_a_spec_skips_to_the_run() {
1473 use StepState::{Now, Skipped, Todo};
1474 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1475 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1476 let only_tasks = JourneyFacts {
1477 tasks: true,
1478 total: 4,
1479 finished: 1,
1480 ..JourneyFacts::default()
1481 };
1482 assert_eq!(
1483 states(only_tasks),
1484 at([
1485 Skipped,
1486 Skipped,
1487 StepState::Done,
1488 StepState::Done,
1489 Now,
1490 Todo
1491 ])
1492 );
1493 let planned = JourneyFacts {
1495 plan: true,
1496 ..JourneyFacts::default()
1497 };
1498 assert_eq!(
1499 states(planned),
1500 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1501 );
1502 }
1503}