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 },
112 KeepSetAside {
115 task: String,
117 },
118 DropSetAside {
120 task: String,
122 },
123 FixSetup,
126 Feature,
128 PullRequest,
130 BugFix {
133 symptom: String,
135 expected: Vec<String>,
137 unchanged: Vec<String>,
139 issue: Option<u64>,
141 tests: Vec<String>,
144 candidate: Option<String>,
146 slug: Option<String>,
148 run: bool,
150 },
151 BugConfirm {
154 task: String,
156 digest: String,
158 reject: bool,
160 },
161 BugKeep {
164 task: String,
166 },
167 BugRun {
169 slug: Option<String>,
171 task: Option<String>,
173 },
174 AllowAlways {
177 task: String,
179 call: String,
181 },
182 WidenPolicy {
185 key: PolicyList,
187 prefix: Vec<String>,
189 digest: String,
191 },
192 DecideBatch {
194 run: String,
196 verdict: BatchVerdict,
198 calls: Vec<String>,
200 digest: String,
202 resume_asking: bool,
204 },
205 OracleTests {
208 req: String,
210 op: crate::oracle::OracleOp,
212 path: Option<String>,
214 scenario: Option<String>,
216 reading: Option<crate::oracle::Reading>,
218 },
219 ApproveAndRun {
222 mode: crate::oracle::RunMode,
224 grants: Vec<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 sheet: Option<String>,
230 },
231 Comment {
233 op: CommentOp,
235 anchor: Option<crate::oracle::CommentAnchor>,
237 id: Option<String>,
239 },
240 CommentsSend {
242 ids: Vec<String>,
245 },
246 RevisionUndo {
248 turn: Option<String>,
250 },
251 CodeNudge {
253 task: String,
255 comments: Vec<crate::oracle::CodeComment>,
257 },
258 AcceptanceAt {
262 feature: String,
264 story: String,
266 task: String,
268 revoke: bool,
270 },
271 PresetAdd {
274 update: bool,
276 },
277 PlanThrough {
281 from: PlanFrom,
283 through: Through,
285 },
286 NewProject {
289 target: String,
291 choices: crate::new_project::NewChoices,
293 plan_digest: String,
295 },
296 UndoNewProject {
299 commit: String,
301 },
302 CloneRepository {
304 repo: String,
306 target: String,
308 },
309 OpenSample {
312 fresh: bool,
314 },
315 SampleChecks,
318}
319
320#[derive(
322 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
323)]
324#[serde(rename_all = "snake_case")]
325pub enum Through {
326 Spec,
328 Plan,
330 Tasks,
332 Analyze,
334}
335
336impl Through {
337 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
339
340 #[must_use]
342 pub const fn as_str(self) -> &'static str {
343 match self {
344 Self::Spec => "spec",
345 Self::Plan => "plan",
346 Self::Tasks => "tasks",
347 Self::Analyze => "analyze",
348 }
349 }
350
351 #[must_use]
353 pub fn parse(s: &str) -> Option<Self> {
354 Self::ALL.into_iter().find(|t| t.as_str() == s)
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
361#[serde(tag = "kind", rename_all = "snake_case")]
362pub enum FeatureSource {
363 Description,
365 Issue {
367 number: u64,
369 },
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
374#[serde(tag = "kind", rename_all = "snake_case")]
375pub enum PlanFrom {
376 Create {
378 description: String,
380 source: FeatureSource,
382 },
383 Continue,
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
389#[serde(rename_all = "snake_case")]
390pub enum CommentOp {
391 Add,
393 Delete,
395 Keep,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
401#[serde(rename_all = "snake_case")]
402pub enum PolicyList {
403 CommandsPrefix,
405 NetworkPrefix,
407}
408
409impl PolicyList {
410 #[must_use]
412 pub const fn as_str(self) -> &'static str {
413 match self {
414 Self::CommandsPrefix => "commands_prefix",
415 Self::NetworkPrefix => "network_prefix",
416 }
417 }
418
419 #[must_use]
421 pub const fn key(self) -> &'static str {
422 match self {
423 Self::CommandsPrefix => "commands.allow_prefix",
424 Self::NetworkPrefix => "sandbox.network_allow_prefix",
425 }
426 }
427
428 #[must_use]
430 pub fn of_key(key: &str) -> Option<Self> {
431 match key {
432 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
433 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
434 _ => None,
435 }
436 }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
441#[serde(rename_all = "snake_case")]
442pub enum BatchVerdict {
443 Approve,
445 Reject,
447}
448
449#[derive(
455 Debug,
456 Clone,
457 Copy,
458 PartialEq,
459 Eq,
460 Hash,
461 PartialOrd,
462 Ord,
463 Serialize,
464 Deserialize,
465 schemars::JsonSchema,
466)]
467#[serde(rename_all = "snake_case")]
468pub enum ActionKind {
469 Skill,
471 RunTask,
473 RunFeature,
475 Converge,
477 Commit,
479 Approve,
481 Reject,
483 Accept,
485 Skip,
487 Nudge,
489 Done,
491 Hint,
493 Rule,
495 Review,
497 IssueComment,
499 QuickChange,
501 KeepSetAside,
503 DropSetAside,
505 FixSetup,
507 Feature,
509 PullRequest,
511 BugFix,
513 BugConfirm,
515 BugRun,
517 BugKeep,
519 AllowAlways,
521 WidenPolicy,
523 DecideBatch,
525 OracleTests,
527 ApproveAndRun,
529 Comment,
531 CommentsSend,
533 RevisionUndo,
535 CodeNudge,
537 AcceptanceAt,
539 PresetAdd,
541 PlanThrough,
543 NewProject,
545 UndoNewProject,
547 CloneRepository,
549 OpenSample,
551 SampleChecks,
553 Command,
555}
556
557impl ActionKind {
558 pub const ALL: &'static [Self] = &[
560 Self::Skill,
561 Self::RunTask,
562 Self::RunFeature,
563 Self::Converge,
564 Self::Commit,
565 Self::Approve,
566 Self::Reject,
567 Self::Accept,
568 Self::Skip,
569 Self::Nudge,
570 Self::Done,
571 Self::Hint,
572 Self::Rule,
573 Self::Review,
574 Self::IssueComment,
575 Self::QuickChange,
576 Self::KeepSetAside,
577 Self::DropSetAside,
578 Self::FixSetup,
579 Self::Feature,
580 Self::PullRequest,
581 Self::BugFix,
582 Self::BugConfirm,
583 Self::BugRun,
584 Self::BugKeep,
585 Self::AllowAlways,
586 Self::WidenPolicy,
587 Self::DecideBatch,
588 Self::OracleTests,
589 Self::ApproveAndRun,
590 Self::Comment,
591 Self::CommentsSend,
592 Self::RevisionUndo,
593 Self::CodeNudge,
594 Self::AcceptanceAt,
595 Self::PresetAdd,
596 Self::PlanThrough,
597 Self::NewProject,
598 Self::UndoNewProject,
599 Self::CloneRepository,
600 Self::OpenSample,
601 Self::SampleChecks,
602 Self::Command,
603 ];
604
605 #[must_use]
607 pub const fn as_str(self) -> &'static str {
608 match self {
609 Self::Skill => "skill",
610 Self::RunTask => "run_task",
611 Self::RunFeature => "run_feature",
612 Self::Converge => "converge",
613 Self::Commit => "commit",
614 Self::Approve => "approve",
615 Self::Reject => "reject",
616 Self::Accept => "accept",
617 Self::Skip => "skip",
618 Self::Nudge => "nudge",
619 Self::Done => "done",
620 Self::Hint => "hint",
621 Self::Rule => "rule",
622 Self::Review => "review",
623 Self::IssueComment => "issue_comment",
624 Self::QuickChange => "quick_change",
625 Self::KeepSetAside => "keep_set_aside",
626 Self::DropSetAside => "drop_set_aside",
627 Self::FixSetup => "fix_setup",
628 Self::Feature => "feature",
629 Self::PullRequest => "pull_request",
630 Self::BugFix => "bug_fix",
631 Self::BugConfirm => "bug_confirm",
632 Self::BugRun => "bug_run",
633 Self::BugKeep => "bug_keep",
634 Self::AllowAlways => "allow_always",
635 Self::WidenPolicy => "widen_policy",
636 Self::DecideBatch => "decide_batch",
637 Self::OracleTests => "oracle_tests",
638 Self::ApproveAndRun => "approve_and_run",
639 Self::Comment => "comment",
640 Self::CommentsSend => "comments_send",
641 Self::RevisionUndo => "revision_undo",
642 Self::CodeNudge => "code_nudge",
643 Self::AcceptanceAt => "acceptance_at",
644 Self::PresetAdd => "preset_add",
645 Self::PlanThrough => "plan_through",
646 Self::NewProject => "new_project",
647 Self::UndoNewProject => "undo_new_project",
648 Self::CloneRepository => "clone_repository",
649 Self::OpenSample => "open_sample",
650 Self::SampleChecks => "sample_checks",
651 Self::Command => "command",
652 }
653 }
654
655 #[must_use]
658 pub fn example(self) -> Option<Runnable> {
659 let task = || "T001".to_owned();
660 Some(match self {
661 Self::Skill => Runnable::Skill {
662 name: "plan".to_owned(),
663 arguments: String::new(),
664 },
665 Self::RunTask => Runnable::Run {
666 task: Some(task()),
667 agent: None,
668 },
669 Self::RunFeature => Runnable::Run {
670 task: None,
671 agent: None,
672 },
673 Self::Converge => Runnable::Converge,
674 Self::Commit => Runnable::Commit {
675 paths: vec!["plan.md".to_owned()],
676 },
677 Self::Approve => Runnable::Approve {
678 task: task(),
679 call: "0f3c".to_owned(),
680 digest: "d1".to_owned(),
681 },
682 Self::Reject => Runnable::Reject {
683 task: task(),
684 call: "0f3c".to_owned(),
685 digest: "d1".to_owned(),
686 },
687 Self::Accept => Runnable::Accept { task: task() },
688 Self::Skip => Runnable::Skip { task: task() },
689 Self::Nudge => Runnable::Nudge { task: task() },
690 Self::Done => Runnable::Done { task: task() },
691 Self::Hint => Runnable::Hint { task: task() },
692 Self::Rule => Runnable::Rule { task: task() },
693 Self::Review => Runnable::Review {
694 ids: vec!["FR-001".to_owned()],
695 verdict: "approve".to_owned(),
696 },
697 Self::IssueComment => Runnable::IssueComment {
698 issue: 42,
699 assign: false,
700 },
701 Self::QuickChange => Runnable::QuickChange {
702 request: "a change".to_owned(),
703 checks: Vec::new(),
704 run: false,
705 },
706 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
707 Self::DropSetAside => Runnable::DropSetAside { task: task() },
708 Self::FixSetup => Runnable::FixSetup,
709 Self::Feature => Runnable::Feature,
710 Self::PullRequest => Runnable::PullRequest,
711 Self::BugFix => Runnable::BugFix {
712 symptom: "saving an empty title crashes".to_owned(),
713 expected: vec!["an empty title is refused".to_owned()],
714 unchanged: Vec::new(),
715 issue: None,
716 tests: Vec::new(),
717 candidate: None,
718 slug: None,
719 run: false,
720 },
721 Self::BugConfirm => Runnable::BugConfirm {
722 task: "007-bugfixes/T001".to_owned(),
723 digest: "sha256:0".to_owned(),
724 reject: false,
725 },
726 Self::BugRun => Runnable::BugRun {
727 slug: None,
728 task: Some("007-bugfixes/T001".to_owned()),
729 },
730 Self::BugKeep => Runnable::BugKeep {
731 task: "007-bugfixes/T001".to_owned(),
732 },
733 Self::AllowAlways => Runnable::AllowAlways {
734 task: task(),
735 call: "0f3c".to_owned(),
736 },
737 Self::WidenPolicy => Runnable::WidenPolicy {
738 key: PolicyList::CommandsPrefix,
739 prefix: vec!["npm".to_owned(), "run".to_owned()],
740 digest: "d1".to_owned(),
741 },
742 Self::DecideBatch => Runnable::DecideBatch {
743 run: "run-1-0a0b0c0d".to_owned(),
744 verdict: BatchVerdict::Approve,
745 calls: vec!["0f3c".to_owned()],
746 digest: "d1".to_owned(),
747 resume_asking: false,
748 },
749 Self::OracleTests => Runnable::OracleTests {
750 req: "US1".to_owned(),
751 op: crate::oracle::OracleOp::Write,
752 path: None,
753 scenario: None,
754 reading: None,
755 },
756 Self::ApproveAndRun => Runnable::ApproveAndRun {
757 mode: crate::oracle::RunMode::Ask,
758 grants: Vec::new(),
759 sheet: None,
760 },
761 Self::Comment => Runnable::Comment {
762 op: CommentOp::Delete,
763 anchor: None,
764 id: Some("c1".to_owned()),
765 },
766 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
767 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
768 Self::CodeNudge => Runnable::CodeNudge {
769 task: task(),
770 comments: vec![crate::oracle::CodeComment {
771 path: "src/lib.rs".to_owned(),
772 line: 1,
773 quote: "pub fn ok() {}".to_owned(),
774 text: "name it".to_owned(),
775 }],
776 },
777 Self::AcceptanceAt => Runnable::AcceptanceAt {
778 feature: "001-greet-function".to_owned(),
779 story: "US1".to_owned(),
780 task: "T003".to_owned(),
781 revoke: false,
782 },
783 Self::PresetAdd => Runnable::PresetAdd { update: false },
784 Self::PlanThrough => Runnable::PlanThrough {
785 from: PlanFrom::Create {
786 description: "Users can export a report as CSV".to_owned(),
787 source: FeatureSource::Description,
788 },
789 through: Through::Tasks,
790 },
791 Self::NewProject => Runnable::NewProject {
792 target: "/tmp/md2csv".to_owned(),
793 choices: crate::new_project::NewChoices {
794 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
795 ..crate::new_project::NewChoices::default()
796 },
797 plan_digest: "sha256:0".to_owned(),
798 },
799 Self::UndoNewProject => Runnable::UndoNewProject {
800 commit: "1a2b3c4".to_owned(),
801 },
802 Self::CloneRepository => Runnable::CloneRepository {
803 repo: "octo/hello".to_owned(),
804 target: "/tmp/hello".to_owned(),
805 },
806 Self::OpenSample => Runnable::OpenSample { fresh: false },
807 Self::SampleChecks => Runnable::SampleChecks,
808 Self::Command => return None,
809 })
810 }
811}
812
813impl Runnable {
814 #[must_use]
816 pub fn examples() -> Vec<Self> {
817 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
818 }
819
820 #[must_use]
822 pub const fn kind(&self) -> ActionKind {
823 match self {
824 Self::Skill { .. } => ActionKind::Skill,
825 Self::Run { task: Some(_), .. } => ActionKind::RunTask,
826 Self::Run { task: None, .. } => ActionKind::RunFeature,
827 Self::Converge => ActionKind::Converge,
828 Self::Commit { .. } => ActionKind::Commit,
829 Self::Approve { .. } => ActionKind::Approve,
830 Self::Reject { .. } => ActionKind::Reject,
831 Self::Accept { .. } => ActionKind::Accept,
832 Self::Skip { .. } => ActionKind::Skip,
833 Self::Nudge { .. } => ActionKind::Nudge,
834 Self::Done { .. } => ActionKind::Done,
835 Self::Hint { .. } => ActionKind::Hint,
836 Self::Rule { .. } => ActionKind::Rule,
837 Self::Review { .. } => ActionKind::Review,
838 Self::IssueComment { .. } => ActionKind::IssueComment,
839 Self::QuickChange { .. } => ActionKind::QuickChange,
840 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
841 Self::DropSetAside { .. } => ActionKind::DropSetAside,
842 Self::FixSetup => ActionKind::FixSetup,
843 Self::Feature => ActionKind::Feature,
844 Self::PullRequest => ActionKind::PullRequest,
845 Self::BugFix { .. } => ActionKind::BugFix,
846 Self::BugConfirm { .. } => ActionKind::BugConfirm,
847 Self::BugRun { .. } => ActionKind::BugRun,
848 Self::BugKeep { .. } => ActionKind::BugKeep,
849 Self::AllowAlways { .. } => ActionKind::AllowAlways,
850 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
851 Self::DecideBatch { .. } => ActionKind::DecideBatch,
852 Self::OracleTests { .. } => ActionKind::OracleTests,
853 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
854 Self::Comment { .. } => ActionKind::Comment,
855 Self::CommentsSend { .. } => ActionKind::CommentsSend,
856 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
857 Self::CodeNudge { .. } => ActionKind::CodeNudge,
858 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
859 Self::PresetAdd { .. } => ActionKind::PresetAdd,
860 Self::PlanThrough { .. } => ActionKind::PlanThrough,
861 Self::NewProject { .. } => ActionKind::NewProject,
862 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
863 Self::CloneRepository { .. } => ActionKind::CloneRepository,
864 Self::OpenSample { .. } => ActionKind::OpenSample,
865 Self::SampleChecks => ActionKind::SampleChecks,
866 }
867 }
868}
869
870#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
874pub struct ActionId {
875 pub kind: ActionKind,
877 pub subject: Option<String>,
880}
881
882#[must_use]
885pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
886 let Some(run) = run else {
887 let words: Vec<&str> = command
889 .split_whitespace()
890 .skip_while(|w| *w == "speccoder")
891 .take_while(|w| {
892 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
893 && w.chars()
894 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
895 })
896 .collect();
897 return ActionId {
898 kind: ActionKind::Command,
899 subject: (!words.is_empty()).then(|| words.join(" ")),
900 };
901 };
902 let subject = match run {
903 Runnable::Skill { name, .. } => Some(name.clone()),
904 Runnable::Run { task, .. } => task.clone(),
905 Runnable::Approve { task, .. }
906 | Runnable::Reject { task, .. }
907 | Runnable::Accept { task }
908 | Runnable::Skip { task }
909 | Runnable::Nudge { task }
910 | Runnable::Done { task }
911 | Runnable::Hint { task }
912 | Runnable::Rule { task }
913 | Runnable::KeepSetAside { task }
914 | Runnable::DropSetAside { task }
915 | Runnable::AllowAlways { task, .. }
916 | Runnable::CodeNudge { task, .. }
917 | Runnable::AcceptanceAt { task, .. } => Some(task.clone()),
918 Runnable::OracleTests { req, .. } => Some(req.clone()),
919 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
920 Runnable::Comment { id, .. } => id.clone(),
921 Runnable::RevisionUndo { turn } => turn.clone(),
922 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
923 Runnable::DecideBatch { run, .. } => Some(run.clone()),
924 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
925 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
926 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
927 Runnable::BugFix { slug, issue, .. } => {
928 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
929 }
930 Runnable::Review { ids, verdict } => Some(match ids.first() {
931 Some(id) => format!("{verdict}:{id}"),
932 None => verdict.clone(),
933 }),
934 Runnable::PlanThrough { from, through } => Some(match from {
935 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
936 PlanFrom::Continue => format!("continue:{}", through.as_str()),
937 }),
938 Runnable::NewProject { target, .. } => Some(target.clone()),
939 Runnable::UndoNewProject { commit } => Some(commit.clone()),
940 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
941 Runnable::Converge
942 | Runnable::OpenSample { .. }
943 | Runnable::SampleChecks
944 | Runnable::Commit { .. }
945 | Runnable::QuickChange { .. }
946 | Runnable::FixSetup
947 | Runnable::Feature
948 | Runnable::PullRequest
949 | Runnable::PresetAdd { .. }
950 | Runnable::CommentsSend { .. } => None,
951 };
952 ActionId {
953 kind: run.kind(),
954 subject,
955 }
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
962#[serde(rename_all = "snake_case")]
963pub enum SkillRole {
964 Create,
966 TowardTasks,
968 Revise,
970 Check,
972 Other,
974}
975
976#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
981#[serde(rename_all = "snake_case")]
982pub enum Via {
983 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
985 Terminal,
986 Web,
988 Window,
990 Acp,
992 Phone,
994 Ci,
996 Mcp,
998}
999
1000impl Via {
1001 #[must_use]
1003 pub const fn as_str(self) -> &'static str {
1004 match self {
1005 Self::Terminal => "terminal",
1006 Self::Web => "web",
1007 Self::Window => "window",
1008 Self::Acp => "acp",
1009 Self::Phone => "phone",
1010 Self::Ci => "ci",
1011 Self::Mcp => "mcp",
1012 }
1013 }
1014
1015 #[must_use]
1018 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1019 if let Some(client) = recorded.strip_prefix("editor ") {
1020 return Some((Self::Acp, Some(client.to_owned())));
1021 }
1022 let via = match recorded {
1023 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1024 "web" => Self::Web,
1025 "window" => Self::Window,
1026 "acp" | "editor" => Self::Acp,
1027 "phone" => Self::Phone,
1028 "ci" => Self::Ci,
1029 "mcp" => Self::Mcp,
1030 _ => return None,
1031 };
1032 Some((via, None))
1033 }
1034}
1035
1036impl std::fmt::Display for Via {
1037 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1038 f.write_str(self.as_str())
1039 }
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1044pub struct Action {
1045 pub group: String,
1047 pub label: String,
1049 pub why: String,
1051 pub command: String,
1053 pub run: Option<Runnable>,
1055 pub step: Option<crate::step::How>,
1058 pub blocked: Option<String>,
1060 pub input: Option<String>,
1062 pub confirm: Option<String>,
1065 pub id: ActionId,
1067 pub role: Option<SkillRole>,
1070}
1071
1072impl Action {
1073 #[must_use]
1075 pub fn new(
1076 group: &str,
1077 label: impl Into<String>,
1078 why: impl Into<String>,
1079 command: impl Into<String>,
1080 ) -> Self {
1081 let command = command.into();
1082 Self {
1083 group: group.to_owned(),
1084 label: label.into(),
1085 why: why.into(),
1086 id: identity(None, &command),
1087 step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1088 command,
1089 run: None,
1090 blocked: None,
1091 input: None,
1092 confirm: None,
1093 role: None,
1094 }
1095 }
1096
1097 #[must_use]
1099 pub fn with_run(mut self, run: Runnable) -> Self {
1100 self.id = identity(Some(&run), &self.command);
1101 self.run = Some(run);
1102 self.step = None;
1103 self
1104 }
1105
1106 #[must_use]
1108 pub const fn with_role(mut self, role: SkillRole) -> Self {
1109 self.role = Some(role);
1110 self
1111 }
1112
1113 #[must_use]
1115 pub const fn runnable(&self) -> bool {
1116 self.run.is_some() && self.blocked.is_none()
1117 }
1118}
1119
1120#[must_use]
1122pub fn primary(actions: &[Action]) -> Option<&Action> {
1123 actions.iter().find(|a| a.group == "next" && a.runnable())
1124}
1125
1126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1128#[serde(rename_all = "snake_case")]
1129pub enum StepState {
1130 Done,
1132 Now,
1134 Todo,
1136 Skipped,
1139}
1140
1141#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1144pub struct JourneyStep {
1145 pub name: String,
1147 pub state: StepState,
1149}
1150
1151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1153pub struct JourneyFacts {
1154 pub spec: bool,
1156 pub plan: bool,
1158 pub tasks: bool,
1160 pub uncommitted: bool,
1162 pub total: usize,
1164 pub finished: usize,
1166}
1167
1168#[must_use]
1172pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1173 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1174 let done = [
1175 ("spec", f.spec),
1176 ("plan", f.plan),
1177 ("tasks", f.tasks && f.total > 0),
1179 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1180 ("run", f.total > 0 && f.finished == f.total),
1181 ("review", false),
1182 ];
1183 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1184 let now = done
1185 .iter()
1186 .enumerate()
1187 .position(|(i, (_, d))| !d && !passed(i))
1188 .unwrap_or(done.len());
1189 done.iter()
1190 .enumerate()
1191 .map(|(i, (name, d))| JourneyStep {
1192 name: (*name).to_owned(),
1193 state: if *d && i < now {
1194 StepState::Done
1195 } else if passed(i) && i < now {
1196 StepState::Skipped
1197 } else if i == now {
1198 StepState::Now
1199 } else {
1200 StepState::Todo
1201 },
1202 })
1203 .collect()
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 #[test]
1211 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1212 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1214 .unwrap_or_else(|e| panic!("{e}"));
1215 let mut named: Vec<String> = schema["oneOf"]
1216 .as_array()
1217 .into_iter()
1218 .flatten()
1219 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1220 .collect();
1221 named.sort();
1222 let mut listed: Vec<String> = ActionKind::ALL
1223 .iter()
1224 .map(|k| k.as_str().to_owned())
1225 .collect();
1226 listed.sort();
1227 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1228 for k in ActionKind::ALL {
1229 assert_eq!(
1230 serde_json::to_value(k).unwrap_or_default(),
1231 k.as_str(),
1232 "{k:?}"
1233 );
1234 if let Some(run) = k.example() {
1235 assert_eq!(run.kind(), *k);
1236 assert_eq!(identity(Some(&run), "").kind, *k);
1237 }
1238 }
1239 assert_eq!(
1240 Runnable::examples().len(),
1241 ActionKind::ALL.len() - 1,
1242 "every kind but `command` has a runnable"
1243 );
1244 }
1245
1246 #[test]
1247 fn identity_is_what_runs_never_how_it_is_worded() {
1248 let a = Action::new(
1249 "task",
1250 "accept T004",
1251 "tick it",
1252 "speccoder task accept T004",
1253 )
1254 .with_run(Runnable::Accept {
1255 task: "T004".to_owned(),
1256 });
1257 let mut b = a.clone();
1258 b.label = "x tick it anyway".to_owned();
1259 b.why = "x".to_owned();
1260 assert_eq!(a.id, b.id);
1261 assert_eq!(
1262 a.id,
1263 ActionId {
1264 kind: ActionKind::Accept,
1265 subject: Some("T004".to_owned())
1266 }
1267 );
1268 let id = |c: &str| identity(None, c).subject;
1269 assert_eq!(
1270 id("speccoder attest verify").as_deref(),
1271 Some("attest verify")
1272 );
1273 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1274 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1275 assert_eq!(
1276 id("speccoder spec specify \"…\"").as_deref(),
1277 Some("spec specify")
1278 );
1279 let review = |ids: &[&str], v: &str| {
1280 identity(
1281 Some(&Runnable::Review {
1282 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1283 verdict: v.to_owned(),
1284 }),
1285 "",
1286 )
1287 .subject
1288 };
1289 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1290 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1291 }
1292
1293 #[test]
1294 fn origins_read_back_from_every_value_ever_recorded() {
1295 for (old, via, client) in [
1296 ("cli", Via::Terminal, None),
1297 ("tui", Via::Terminal, None),
1298 ("dashboard", Via::Terminal, None),
1299 ("terminal", Via::Terminal, None),
1300 ("web", Via::Web, None),
1301 ("editor Zed", Via::Acp, Some("Zed")),
1302 ] {
1303 assert_eq!(
1304 Via::read(old),
1305 Some((via, client.map(str::to_owned))),
1306 "{old}"
1307 );
1308 }
1309 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1310 assert_eq!(parsed, Via::Terminal);
1311 assert_eq!(Via::Acp.as_str(), "acp");
1312 assert_eq!(Via::read("nobody"), None);
1313 }
1314
1315 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1316 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1317 }
1318
1319 #[test]
1320 fn the_journey_marks_what_is_done_and_what_is_now() {
1321 use StepState::{Done, Now, Todo};
1322 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1323 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1324 assert_eq!(
1325 states(JourneyFacts::default()),
1326 at([Now, Todo, Todo, Todo, Todo, Todo])
1327 );
1328 let planned = JourneyFacts {
1329 spec: true,
1330 plan: true,
1331 ..JourneyFacts::default()
1332 };
1333 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1334 let written = JourneyFacts {
1335 tasks: true,
1336 uncommitted: true,
1337 total: 3,
1338 ..planned
1339 };
1340 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1341 let running = JourneyFacts {
1342 uncommitted: false,
1343 finished: 1,
1344 ..written
1345 };
1346 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1347 let finished = JourneyFacts {
1348 finished: 3,
1349 ..running
1350 };
1351 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1352 let empty = JourneyFacts {
1354 tasks: true,
1355 ..planned
1356 };
1357 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1358 }
1359
1360 #[test]
1361 fn a_task_list_without_a_spec_skips_to_the_run() {
1362 use StepState::{Now, Skipped, Todo};
1363 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1364 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1365 let only_tasks = JourneyFacts {
1366 tasks: true,
1367 total: 4,
1368 finished: 1,
1369 ..JourneyFacts::default()
1370 };
1371 assert_eq!(
1372 states(only_tasks),
1373 at([
1374 Skipped,
1375 Skipped,
1376 StepState::Done,
1377 StepState::Done,
1378 Now,
1379 Todo
1380 ])
1381 );
1382 let planned = JourneyFacts {
1384 plan: true,
1385 ..JourneyFacts::default()
1386 };
1387 assert_eq!(
1388 states(planned),
1389 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1390 );
1391 }
1392}