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