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 PresetAdd {
280 update: bool,
282 },
283 PlanThrough {
287 from: PlanFrom,
289 through: Through,
291 },
292 NewProject {
295 target: String,
297 choices: crate::new_project::NewChoices,
299 plan_digest: String,
301 },
302 UndoNewProject {
305 commit: String,
307 },
308 CloneRepository {
310 repo: String,
312 target: String,
314 },
315 OpenSample {
318 fresh: bool,
320 },
321 SampleChecks,
324 DeclinePromotion {
327 task: String,
329 },
330 Explore {
333 idea: String,
335 #[serde(default)]
337 skill: Option<String>,
338 #[serde(default)]
340 resume: Option<String>,
341 },
342 ExploreChoose {
345 session: String,
347 approach: usize,
349 #[serde(default)]
351 constraints: Vec<String>,
352 #[serde(default)]
354 questions: Vec<String>,
355 #[serde(default)]
357 flag: bool,
358 },
359}
360
361#[derive(
363 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
364)]
365#[serde(rename_all = "snake_case")]
366pub enum Through {
367 Spec,
369 Plan,
371 Tasks,
373 Analyze,
375}
376
377impl Through {
378 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
380
381 #[must_use]
383 pub const fn as_str(self) -> &'static str {
384 match self {
385 Self::Spec => "spec",
386 Self::Plan => "plan",
387 Self::Tasks => "tasks",
388 Self::Analyze => "analyze",
389 }
390 }
391
392 #[must_use]
394 pub fn parse(s: &str) -> Option<Self> {
395 Self::ALL.into_iter().find(|t| t.as_str() == s)
396 }
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
402#[serde(tag = "kind", rename_all = "snake_case")]
403pub enum FeatureSource {
404 Description,
406 Issue {
408 number: u64,
410 },
411 Promotion {
414 task: String,
416 #[serde(default)]
418 conversations: Vec<String>,
419 },
420 Explore {
422 session: String,
424 digest: String,
426 },
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
431#[serde(tag = "kind", rename_all = "snake_case")]
432pub enum PlanFrom {
433 Create {
435 description: String,
437 source: FeatureSource,
439 },
440 Continue,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
446#[serde(rename_all = "snake_case")]
447pub enum CommentOp {
448 Add,
450 Delete,
452 Keep,
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
458#[serde(rename_all = "snake_case")]
459pub enum PolicyList {
460 CommandsPrefix,
462 NetworkPrefix,
464}
465
466impl PolicyList {
467 #[must_use]
469 pub const fn as_str(self) -> &'static str {
470 match self {
471 Self::CommandsPrefix => "commands_prefix",
472 Self::NetworkPrefix => "network_prefix",
473 }
474 }
475
476 #[must_use]
478 pub const fn key(self) -> &'static str {
479 match self {
480 Self::CommandsPrefix => "commands.allow_prefix",
481 Self::NetworkPrefix => "sandbox.network_allow_prefix",
482 }
483 }
484
485 #[must_use]
487 pub fn of_key(key: &str) -> Option<Self> {
488 match key {
489 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
490 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
491 _ => None,
492 }
493 }
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
498#[serde(rename_all = "snake_case")]
499pub enum BatchVerdict {
500 Approve,
502 Reject,
504}
505
506#[derive(
512 Debug,
513 Clone,
514 Copy,
515 PartialEq,
516 Eq,
517 Hash,
518 PartialOrd,
519 Ord,
520 Serialize,
521 Deserialize,
522 schemars::JsonSchema,
523)]
524#[serde(rename_all = "snake_case")]
525pub enum ActionKind {
526 Skill,
528 RunTask,
530 RunFeature,
532 Converge,
534 Commit,
536 Approve,
538 Reject,
540 Accept,
542 Skip,
544 Nudge,
546 Done,
548 Hint,
550 Rule,
552 Review,
554 IssueComment,
556 QuickChange,
558 KeepSetAside,
560 DropSetAside,
562 FixSetup,
564 Feature,
566 PullRequest,
568 BugFix,
570 BugConfirm,
572 BugRun,
574 BugKeep,
576 AllowAlways,
578 WidenPolicy,
580 DecideBatch,
582 OracleTests,
584 ApproveAndRun,
586 Comment,
588 CommentsSend,
590 RevisionUndo,
592 CodeNudge,
594 AcceptanceAt,
596 PresetAdd,
598 PlanThrough,
600 NewProject,
602 UndoNewProject,
604 CloneRepository,
606 OpenSample,
608 SampleChecks,
610 DeclinePromotion,
612 Explore,
614 ExploreChoose,
616 Command,
618}
619
620impl ActionKind {
621 pub const ALL: &'static [Self] = &[
623 Self::Skill,
624 Self::RunTask,
625 Self::RunFeature,
626 Self::Converge,
627 Self::Commit,
628 Self::Approve,
629 Self::Reject,
630 Self::Accept,
631 Self::Skip,
632 Self::Nudge,
633 Self::Done,
634 Self::Hint,
635 Self::Rule,
636 Self::Review,
637 Self::IssueComment,
638 Self::QuickChange,
639 Self::KeepSetAside,
640 Self::DropSetAside,
641 Self::FixSetup,
642 Self::Feature,
643 Self::PullRequest,
644 Self::BugFix,
645 Self::BugConfirm,
646 Self::BugRun,
647 Self::BugKeep,
648 Self::AllowAlways,
649 Self::WidenPolicy,
650 Self::DecideBatch,
651 Self::OracleTests,
652 Self::ApproveAndRun,
653 Self::Comment,
654 Self::CommentsSend,
655 Self::RevisionUndo,
656 Self::CodeNudge,
657 Self::AcceptanceAt,
658 Self::PresetAdd,
659 Self::PlanThrough,
660 Self::NewProject,
661 Self::UndoNewProject,
662 Self::CloneRepository,
663 Self::OpenSample,
664 Self::SampleChecks,
665 Self::DeclinePromotion,
666 Self::Explore,
667 Self::ExploreChoose,
668 Self::Command,
669 ];
670
671 #[must_use]
673 pub const fn as_str(self) -> &'static str {
674 match self {
675 Self::Skill => "skill",
676 Self::RunTask => "run_task",
677 Self::RunFeature => "run_feature",
678 Self::Converge => "converge",
679 Self::Commit => "commit",
680 Self::Approve => "approve",
681 Self::Reject => "reject",
682 Self::Accept => "accept",
683 Self::Skip => "skip",
684 Self::Nudge => "nudge",
685 Self::Done => "done",
686 Self::Hint => "hint",
687 Self::Rule => "rule",
688 Self::Review => "review",
689 Self::IssueComment => "issue_comment",
690 Self::QuickChange => "quick_change",
691 Self::KeepSetAside => "keep_set_aside",
692 Self::DropSetAside => "drop_set_aside",
693 Self::FixSetup => "fix_setup",
694 Self::Feature => "feature",
695 Self::PullRequest => "pull_request",
696 Self::BugFix => "bug_fix",
697 Self::BugConfirm => "bug_confirm",
698 Self::BugRun => "bug_run",
699 Self::BugKeep => "bug_keep",
700 Self::AllowAlways => "allow_always",
701 Self::WidenPolicy => "widen_policy",
702 Self::DecideBatch => "decide_batch",
703 Self::OracleTests => "oracle_tests",
704 Self::ApproveAndRun => "approve_and_run",
705 Self::Comment => "comment",
706 Self::CommentsSend => "comments_send",
707 Self::RevisionUndo => "revision_undo",
708 Self::CodeNudge => "code_nudge",
709 Self::AcceptanceAt => "acceptance_at",
710 Self::PresetAdd => "preset_add",
711 Self::PlanThrough => "plan_through",
712 Self::NewProject => "new_project",
713 Self::UndoNewProject => "undo_new_project",
714 Self::CloneRepository => "clone_repository",
715 Self::OpenSample => "open_sample",
716 Self::SampleChecks => "sample_checks",
717 Self::DeclinePromotion => "decline_promotion",
718 Self::Explore => "explore",
719 Self::ExploreChoose => "explore_choose",
720 Self::Command => "command",
721 }
722 }
723
724 #[must_use]
727 pub fn example(self) -> Option<Runnable> {
728 let task = || "T001".to_owned();
729 Some(match self {
730 Self::Skill => Runnable::Skill {
731 name: "plan".to_owned(),
732 arguments: String::new(),
733 },
734 Self::RunTask => Runnable::Run {
735 task: Some(task()),
736 agent: None,
737 },
738 Self::RunFeature => Runnable::Run {
739 task: None,
740 agent: None,
741 },
742 Self::Converge => Runnable::Converge,
743 Self::Commit => Runnable::Commit {
744 paths: vec!["plan.md".to_owned()],
745 },
746 Self::Approve => Runnable::Approve {
747 task: task(),
748 call: "0f3c".to_owned(),
749 digest: "d1".to_owned(),
750 },
751 Self::Reject => Runnable::Reject {
752 task: task(),
753 call: "0f3c".to_owned(),
754 digest: "d1".to_owned(),
755 },
756 Self::Accept => Runnable::Accept { task: task() },
757 Self::Skip => Runnable::Skip { task: task() },
758 Self::Nudge => Runnable::Nudge { task: task() },
759 Self::Done => Runnable::Done { task: task() },
760 Self::Hint => Runnable::Hint { task: task() },
761 Self::Rule => Runnable::Rule { task: task() },
762 Self::Review => Runnable::Review {
763 ids: vec!["FR-001".to_owned()],
764 verdict: "approve".to_owned(),
765 },
766 Self::IssueComment => Runnable::IssueComment {
767 issue: 42,
768 assign: false,
769 },
770 Self::QuickChange => Runnable::QuickChange {
771 request: "a change".to_owned(),
772 checks: Vec::new(),
773 run: false,
774 asked: None,
775 },
776 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
777 Self::DropSetAside => Runnable::DropSetAside { task: task() },
778 Self::FixSetup => Runnable::FixSetup,
779 Self::Feature => Runnable::Feature,
780 Self::PullRequest => Runnable::PullRequest,
781 Self::BugFix => Runnable::BugFix {
782 symptom: "saving an empty title crashes".to_owned(),
783 expected: vec!["an empty title is refused".to_owned()],
784 unchanged: Vec::new(),
785 issue: None,
786 tests: Vec::new(),
787 candidate: None,
788 slug: None,
789 run: false,
790 asked: None,
791 },
792 Self::BugConfirm => Runnable::BugConfirm {
793 task: "007-bugfixes/T001".to_owned(),
794 digest: "sha256:0".to_owned(),
795 reject: false,
796 },
797 Self::BugRun => Runnable::BugRun {
798 slug: None,
799 task: Some("007-bugfixes/T001".to_owned()),
800 },
801 Self::BugKeep => Runnable::BugKeep {
802 task: "007-bugfixes/T001".to_owned(),
803 },
804 Self::AllowAlways => Runnable::AllowAlways {
805 task: task(),
806 call: "0f3c".to_owned(),
807 },
808 Self::WidenPolicy => Runnable::WidenPolicy {
809 key: PolicyList::CommandsPrefix,
810 prefix: vec!["npm".to_owned(), "run".to_owned()],
811 digest: "d1".to_owned(),
812 },
813 Self::DecideBatch => Runnable::DecideBatch {
814 run: "run-1-0a0b0c0d".to_owned(),
815 verdict: BatchVerdict::Approve,
816 calls: vec!["0f3c".to_owned()],
817 digest: "d1".to_owned(),
818 resume_asking: false,
819 },
820 Self::OracleTests => Runnable::OracleTests {
821 req: "US1".to_owned(),
822 op: crate::oracle::OracleOp::Write,
823 path: None,
824 scenario: None,
825 reading: None,
826 },
827 Self::ApproveAndRun => Runnable::ApproveAndRun {
828 mode: crate::oracle::RunMode::Ask,
829 grants: Vec::new(),
830 sheet: None,
831 },
832 Self::Comment => Runnable::Comment {
833 op: CommentOp::Delete,
834 anchor: None,
835 id: Some("c1".to_owned()),
836 },
837 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
838 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
839 Self::CodeNudge => Runnable::CodeNudge {
840 task: task(),
841 comments: vec![crate::oracle::CodeComment {
842 path: "src/lib.rs".to_owned(),
843 line: 1,
844 quote: "pub fn ok() {}".to_owned(),
845 text: "name it".to_owned(),
846 }],
847 },
848 Self::AcceptanceAt => Runnable::AcceptanceAt {
849 feature: "001-greet-function".to_owned(),
850 story: "US1".to_owned(),
851 task: "T003".to_owned(),
852 revoke: false,
853 },
854 Self::PresetAdd => Runnable::PresetAdd { update: false },
855 Self::PlanThrough => Runnable::PlanThrough {
856 from: PlanFrom::Create {
857 description: "Users can export a report as CSV".to_owned(),
858 source: FeatureSource::Description,
859 },
860 through: Through::Tasks,
861 },
862 Self::NewProject => Runnable::NewProject {
863 target: "/tmp/md2csv".to_owned(),
864 choices: crate::new_project::NewChoices {
865 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
866 ..crate::new_project::NewChoices::default()
867 },
868 plan_digest: "sha256:0".to_owned(),
869 },
870 Self::UndoNewProject => Runnable::UndoNewProject {
871 commit: "1a2b3c4".to_owned(),
872 },
873 Self::CloneRepository => Runnable::CloneRepository {
874 repo: "octo/hello".to_owned(),
875 target: "/tmp/hello".to_owned(),
876 },
877 Self::OpenSample => Runnable::OpenSample { fresh: false },
878 Self::SampleChecks => Runnable::SampleChecks,
879 Self::DeclinePromotion => Runnable::DeclinePromotion {
880 task: "004-quick-changes/T002".to_owned(),
881 },
882 Self::Explore => Runnable::Explore {
883 idea: "export reports as CSV".to_owned(),
884 skill: None,
885 resume: None,
886 },
887 Self::ExploreChoose => Runnable::ExploreChoose {
888 session: "ex-1a2b3c4d5e6f".to_owned(),
889 approach: 2,
890 constraints: vec!["no new dependency".to_owned()],
891 questions: Vec::new(),
892 flag: false,
893 },
894 Self::Command => return None,
895 })
896 }
897}
898
899impl Runnable {
900 #[must_use]
902 pub fn examples() -> Vec<Self> {
903 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
904 }
905
906 #[must_use]
908 pub const fn kind(&self) -> ActionKind {
909 match self {
910 Self::Skill { .. } => ActionKind::Skill,
911 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
912 Self::Run { task: None, .. } => ActionKind::RunFeature,
913 Self::Converge => ActionKind::Converge,
914 Self::Commit { .. } => ActionKind::Commit,
915 Self::Approve { .. } => ActionKind::Approve,
916 Self::Reject { .. } => ActionKind::Reject,
917 Self::Accept { .. } => ActionKind::Accept,
918 Self::Skip { .. } => ActionKind::Skip,
919 Self::Nudge { .. } => ActionKind::Nudge,
920 Self::Done { .. } => ActionKind::Done,
921 Self::Hint { .. } => ActionKind::Hint,
922 Self::Rule { .. } => ActionKind::Rule,
923 Self::Review { .. } => ActionKind::Review,
924 Self::IssueComment { .. } => ActionKind::IssueComment,
925 Self::QuickChange { .. } => ActionKind::QuickChange,
926 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
927 Self::DropSetAside { .. } => ActionKind::DropSetAside,
928 Self::FixSetup => ActionKind::FixSetup,
929 Self::Feature => ActionKind::Feature,
930 Self::PullRequest => ActionKind::PullRequest,
931 Self::BugFix { .. } => ActionKind::BugFix,
932 Self::BugConfirm { .. } => ActionKind::BugConfirm,
933 Self::BugRun { .. } => ActionKind::BugRun,
934 Self::BugKeep { .. } => ActionKind::BugKeep,
935 Self::AllowAlways { .. } => ActionKind::AllowAlways,
936 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
937 Self::DecideBatch { .. } => ActionKind::DecideBatch,
938 Self::OracleTests { .. } => ActionKind::OracleTests,
939 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
940 Self::Comment { .. } => ActionKind::Comment,
941 Self::CommentsSend { .. } => ActionKind::CommentsSend,
942 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
943 Self::CodeNudge { .. } => ActionKind::CodeNudge,
944 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
945 Self::PresetAdd { .. } => ActionKind::PresetAdd,
946 Self::PlanThrough { .. } => ActionKind::PlanThrough,
947 Self::NewProject { .. } => ActionKind::NewProject,
948 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
949 Self::CloneRepository { .. } => ActionKind::CloneRepository,
950 Self::OpenSample { .. } => ActionKind::OpenSample,
951 Self::SampleChecks => ActionKind::SampleChecks,
952 Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
953 Self::Explore { .. } => ActionKind::Explore,
954 Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
955 }
956 }
957}
958
959#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
963pub struct ActionId {
964 pub kind: ActionKind,
966 pub subject: Option<String>,
969}
970
971#[must_use]
974pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
975 let Some(run) = run else {
976 let words: Vec<&str> = command
978 .split_whitespace()
979 .skip_while(|w| *w == "speccoder")
980 .take_while(|w| {
981 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
982 && w.chars()
983 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
984 })
985 .collect();
986 return ActionId {
987 kind: ActionKind::Command,
988 subject: (!words.is_empty()).then(|| words.join(" ")),
989 };
990 };
991 let subject = match run {
992 Runnable::Skill { name, .. } => Some(name.clone()),
993 Runnable::Run { task, .. } => task.clone(),
994 Runnable::Approve { task, .. }
995 | Runnable::Reject { task, .. }
996 | Runnable::Accept { task }
997 | Runnable::Skip { task }
998 | Runnable::Nudge { task }
999 | Runnable::Done { task }
1000 | Runnable::Hint { task }
1001 | Runnable::Rule { task }
1002 | Runnable::KeepSetAside { task }
1003 | Runnable::DropSetAside { task }
1004 | Runnable::AllowAlways { task, .. }
1005 | Runnable::CodeNudge { task, .. }
1006 | Runnable::AcceptanceAt { task, .. }
1007 | Runnable::DeclinePromotion { task } => Some(task.clone()),
1008 Runnable::Explore { resume, .. } => resume.clone(),
1009 Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1010 Runnable::OracleTests { req, .. } => Some(req.clone()),
1011 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1012 Runnable::Comment { id, .. } => id.clone(),
1013 Runnable::RevisionUndo { turn } => turn.clone(),
1014 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1015 Runnable::DecideBatch { run, .. } => Some(run.clone()),
1016 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1017 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1018 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1019 Runnable::BugFix { slug, issue, .. } => {
1020 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1021 }
1022 Runnable::Review { ids, verdict } => Some(match ids.first() {
1023 Some(id) => format!("{verdict}:{id}"),
1024 None => verdict.clone(),
1025 }),
1026 Runnable::PlanThrough { from, through } => Some(match from {
1027 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1028 PlanFrom::Continue => format!("continue:{}", through.as_str()),
1029 }),
1030 Runnable::NewProject { target, .. } => Some(target.clone()),
1031 Runnable::UndoNewProject { commit } => Some(commit.clone()),
1032 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1033 Runnable::Converge
1034 | Runnable::OpenSample { .. }
1035 | Runnable::SampleChecks
1036 | Runnable::Commit { .. }
1037 | Runnable::QuickChange { .. }
1038 | Runnable::FixSetup
1039 | Runnable::Feature
1040 | Runnable::PullRequest
1041 | Runnable::PresetAdd { .. }
1042 | Runnable::CommentsSend { .. } => None,
1043 };
1044 ActionId {
1045 kind: run.kind(),
1046 subject,
1047 }
1048}
1049
1050#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1054#[serde(rename_all = "snake_case")]
1055pub enum SkillRole {
1056 Create,
1058 TowardTasks,
1060 Revise,
1062 Check,
1064 Other,
1066}
1067
1068#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1073#[serde(rename_all = "snake_case")]
1074pub enum Via {
1075 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1077 Terminal,
1078 Web,
1080 Window,
1082 Acp,
1084 Phone,
1086 Ci,
1088 Mcp,
1090}
1091
1092impl Via {
1093 #[must_use]
1095 pub const fn as_str(self) -> &'static str {
1096 match self {
1097 Self::Terminal => "terminal",
1098 Self::Web => "web",
1099 Self::Window => "window",
1100 Self::Acp => "acp",
1101 Self::Phone => "phone",
1102 Self::Ci => "ci",
1103 Self::Mcp => "mcp",
1104 }
1105 }
1106
1107 #[must_use]
1110 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1111 if let Some(client) = recorded.strip_prefix("editor ") {
1112 return Some((Self::Acp, Some(client.to_owned())));
1113 }
1114 let via = match recorded {
1115 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1116 "web" => Self::Web,
1117 "window" => Self::Window,
1118 "acp" | "editor" => Self::Acp,
1119 "phone" => Self::Phone,
1120 "ci" => Self::Ci,
1121 "mcp" => Self::Mcp,
1122 _ => return None,
1123 };
1124 Some((via, None))
1125 }
1126}
1127
1128impl std::fmt::Display for Via {
1129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1130 f.write_str(self.as_str())
1131 }
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1136pub struct Action {
1137 pub group: String,
1139 pub label: String,
1141 pub why: String,
1143 pub command: String,
1145 pub run: Option<Runnable>,
1147 pub step: Option<crate::step::How>,
1150 pub blocked: Option<String>,
1152 pub input: Option<String>,
1154 pub confirm: Option<String>,
1157 pub id: ActionId,
1159 pub role: Option<SkillRole>,
1162}
1163
1164impl Action {
1165 #[must_use]
1167 pub fn new(
1168 group: &str,
1169 label: impl Into<String>,
1170 why: impl Into<String>,
1171 command: impl Into<String>,
1172 ) -> Self {
1173 let command = command.into();
1174 Self {
1175 group: group.to_owned(),
1176 label: label.into(),
1177 why: why.into(),
1178 id: identity(None, &command),
1179 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1180 command,
1181 run: None,
1182 blocked: None,
1183 input: None,
1184 confirm: None,
1185 role: None,
1186 }
1187 }
1188
1189 #[must_use]
1191 pub fn with_run(mut self, run: Runnable) -> Self {
1192 self.id = identity(Some(&run), &self.command);
1193 self.run = Some(run);
1194 self.step = None;
1195 self
1196 }
1197
1198 #[must_use]
1200 pub const fn with_role(mut self, role: SkillRole) -> Self {
1201 self.role = Some(role);
1202 self
1203 }
1204
1205 #[must_use]
1207 pub const fn runnable(&self) -> bool {
1208 self.run.is_some() && self.blocked.is_none()
1209 }
1210}
1211
1212#[must_use]
1214pub fn primary(actions: &[Action]) -> Option<&Action> {
1215 actions.iter().find(|a| a.group == "next" && a.runnable())
1216}
1217
1218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1220#[serde(rename_all = "snake_case")]
1221pub enum StepState {
1222 Done,
1224 Now,
1226 Todo,
1228 Skipped,
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1236pub struct JourneyStep {
1237 pub name: String,
1239 pub state: StepState,
1241}
1242
1243#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1245pub struct JourneyFacts {
1246 pub spec: bool,
1248 pub plan: bool,
1250 pub tasks: bool,
1252 pub uncommitted: bool,
1254 pub total: usize,
1256 pub finished: usize,
1258}
1259
1260#[must_use]
1264pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1265 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1266 let done = [
1267 ("spec", f.spec),
1268 ("plan", f.plan),
1269 ("tasks", f.tasks && f.total > 0),
1271 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1272 ("run", f.total > 0 && f.finished == f.total),
1273 ("review", false),
1274 ];
1275 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1276 let now = done
1277 .iter()
1278 .enumerate()
1279 .position(|(i, (_, d))| !d && !passed(i))
1280 .unwrap_or(done.len());
1281 done.iter()
1282 .enumerate()
1283 .map(|(i, (name, d))| JourneyStep {
1284 name: (*name).to_owned(),
1285 state: if *d && i < now {
1286 StepState::Done
1287 } else if passed(i) && i < now {
1288 StepState::Skipped
1289 } else if i == now {
1290 StepState::Now
1291 } else {
1292 StepState::Todo
1293 },
1294 })
1295 .collect()
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300 use super::*;
1301
1302 #[test]
1303 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1304 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1306 .unwrap_or_else(|e| panic!("{e}"));
1307 let mut named: Vec<String> = schema["oneOf"]
1308 .as_array()
1309 .into_iter()
1310 .flatten()
1311 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1312 .collect();
1313 named.sort();
1314 let mut listed: Vec<String> = ActionKind::ALL
1315 .iter()
1316 .map(|k| k.as_str().to_owned())
1317 .collect();
1318 listed.sort();
1319 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1320 for k in ActionKind::ALL {
1321 assert_eq!(
1322 serde_json::to_value(k).unwrap_or_default(),
1323 k.as_str(),
1324 "{k:?}"
1325 );
1326 if let Some(run) = k.example() {
1327 assert_eq!(run.kind(), *k);
1328 assert_eq!(identity(Some(&run), "").kind, *k);
1329 }
1330 }
1331 assert_eq!(
1332 Runnable::examples().len(),
1333 ActionKind::ALL.len() - 1,
1334 "every kind but `command` has a runnable"
1335 );
1336 }
1337
1338 #[test]
1339 fn identity_is_what_runs_never_how_it_is_worded() {
1340 let a = Action::new(
1341 "task",
1342 "accept T004",
1343 "tick it",
1344 "speccoder task accept T004",
1345 )
1346 .with_run(Runnable::Accept {
1347 task: "T004".to_owned(),
1348 });
1349 let mut b = a.clone();
1350 b.label = "x tick it anyway".to_owned();
1351 b.why = "x".to_owned();
1352 assert_eq!(a.id, b.id);
1353 assert_eq!(
1354 a.id,
1355 ActionId {
1356 kind: ActionKind::Accept,
1357 subject: Some("T004".to_owned())
1358 }
1359 );
1360 let id = |c: &str| identity(None, c).subject;
1361 assert_eq!(
1362 id("speccoder attest verify").as_deref(),
1363 Some("attest verify")
1364 );
1365 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1366 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1367 assert_eq!(
1368 id("speccoder spec specify \"…\"").as_deref(),
1369 Some("spec specify")
1370 );
1371 let review = |ids: &[&str], v: &str| {
1372 identity(
1373 Some(&Runnable::Review {
1374 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1375 verdict: v.to_owned(),
1376 }),
1377 "",
1378 )
1379 .subject
1380 };
1381 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1382 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1383 }
1384
1385 #[test]
1386 fn origins_read_back_from_every_value_ever_recorded() {
1387 for (old, via, client) in [
1388 ("cli", Via::Terminal, None),
1389 ("tui", Via::Terminal, None),
1390 ("dashboard", Via::Terminal, None),
1391 ("terminal", Via::Terminal, None),
1392 ("web", Via::Web, None),
1393 ("editor Zed", Via::Acp, Some("Zed")),
1394 ] {
1395 assert_eq!(
1396 Via::read(old),
1397 Some((via, client.map(str::to_owned))),
1398 "{old}"
1399 );
1400 }
1401 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1402 assert_eq!(parsed, Via::Terminal);
1403 assert_eq!(Via::Acp.as_str(), "acp");
1404 assert_eq!(Via::read("nobody"), None);
1405 }
1406
1407 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1408 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1409 }
1410
1411 #[test]
1412 fn the_journey_marks_what_is_done_and_what_is_now() {
1413 use StepState::{Done, Now, Todo};
1414 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1415 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1416 assert_eq!(
1417 states(JourneyFacts::default()),
1418 at([Now, Todo, Todo, Todo, Todo, Todo])
1419 );
1420 let planned = JourneyFacts {
1421 spec: true,
1422 plan: true,
1423 ..JourneyFacts::default()
1424 };
1425 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1426 let written = JourneyFacts {
1427 tasks: true,
1428 uncommitted: true,
1429 total: 3,
1430 ..planned
1431 };
1432 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1433 let running = JourneyFacts {
1434 uncommitted: false,
1435 finished: 1,
1436 ..written
1437 };
1438 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1439 let finished = JourneyFacts {
1440 finished: 3,
1441 ..running
1442 };
1443 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1444 let empty = JourneyFacts {
1446 tasks: true,
1447 ..planned
1448 };
1449 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1450 }
1451
1452 #[test]
1453 fn a_task_list_without_a_spec_skips_to_the_run() {
1454 use StepState::{Now, Skipped, Todo};
1455 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1456 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1457 let only_tasks = JourneyFacts {
1458 tasks: true,
1459 total: 4,
1460 finished: 1,
1461 ..JourneyFacts::default()
1462 };
1463 assert_eq!(
1464 states(only_tasks),
1465 at([
1466 Skipped,
1467 Skipped,
1468 StepState::Done,
1469 StepState::Done,
1470 Now,
1471 Todo
1472 ])
1473 );
1474 let planned = JourneyFacts {
1476 plan: true,
1477 ..JourneyFacts::default()
1478 };
1479 assert_eq!(
1480 states(planned),
1481 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1482 );
1483 }
1484}