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