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 },
24 Converge,
27 Commit {
29 paths: Vec<String>,
31 },
32 Approve {
35 task: String,
37 call: String,
39 digest: String,
41 },
42 Reject {
44 task: String,
46 call: String,
48 digest: String,
50 },
51 Accept {
53 task: String,
55 },
56 Skip {
58 task: String,
60 },
61 Nudge {
63 task: String,
65 },
66 Rule {
68 task: String,
70 },
71 Review {
74 ids: Vec<String>,
76 verdict: String,
80 },
81 IssueComment {
83 issue: u64,
85 assign: bool,
87 },
88 QuickChange {
90 request: String,
92 checks: Vec<String>,
94 run: bool,
96 },
97 KeepSetAside {
100 task: String,
102 },
103 DropSetAside {
105 task: String,
107 },
108 FixSetup,
111 Feature,
113 PullRequest,
115 BugFix {
118 symptom: String,
120 expected: Vec<String>,
122 unchanged: Vec<String>,
124 issue: Option<u64>,
126 tests: Vec<String>,
129 candidate: Option<String>,
131 slug: Option<String>,
133 run: bool,
135 },
136 BugConfirm {
139 task: String,
141 digest: String,
143 reject: bool,
145 },
146 BugKeep {
149 task: String,
151 },
152 BugRun {
154 slug: Option<String>,
156 task: Option<String>,
158 },
159 AllowAlways {
162 task: String,
164 call: String,
166 },
167 WidenPolicy {
170 key: PolicyList,
172 prefix: Vec<String>,
174 digest: String,
176 },
177 DecideBatch {
179 run: String,
181 verdict: BatchVerdict,
183 calls: Vec<String>,
185 digest: String,
187 resume_asking: bool,
189 },
190 OracleTests {
193 req: String,
195 op: crate::oracle::OracleOp,
197 path: Option<String>,
199 scenario: Option<String>,
201 reading: Option<crate::oracle::Reading>,
203 },
204 ApproveAndRun {
207 mode: crate::oracle::RunMode,
209 grants: Vec<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 sheet: Option<String>,
215 },
216 Comment {
218 op: CommentOp,
220 anchor: Option<crate::oracle::CommentAnchor>,
222 id: Option<String>,
224 },
225 CommentsSend {
227 ids: Vec<String>,
230 },
231 RevisionUndo {
233 turn: Option<String>,
235 },
236 CodeNudge {
238 task: String,
240 comments: Vec<crate::oracle::CodeComment>,
242 },
243 AcceptanceAt {
247 feature: String,
249 story: String,
251 task: String,
253 revoke: bool,
255 },
256 PresetAdd {
259 update: bool,
261 },
262 PlanThrough {
266 from: PlanFrom,
268 through: Through,
270 },
271 NewProject {
274 target: String,
276 choices: crate::new_project::NewChoices,
278 plan_digest: String,
280 },
281 UndoNewProject {
284 commit: String,
286 },
287 CloneRepository {
289 repo: String,
291 target: String,
293 },
294 OpenSample {
297 fresh: bool,
299 },
300 SampleChecks,
303}
304
305#[derive(
307 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
308)]
309#[serde(rename_all = "snake_case")]
310pub enum Through {
311 Spec,
313 Plan,
315 Tasks,
317 Analyze,
319}
320
321impl Through {
322 pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
324
325 #[must_use]
327 pub const fn as_str(self) -> &'static str {
328 match self {
329 Self::Spec => "spec",
330 Self::Plan => "plan",
331 Self::Tasks => "tasks",
332 Self::Analyze => "analyze",
333 }
334 }
335
336 #[must_use]
338 pub fn parse(s: &str) -> Option<Self> {
339 Self::ALL.into_iter().find(|t| t.as_str() == s)
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
346#[serde(tag = "kind", rename_all = "snake_case")]
347pub enum FeatureSource {
348 Description,
350 Issue {
352 number: u64,
354 },
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
359#[serde(tag = "kind", rename_all = "snake_case")]
360pub enum PlanFrom {
361 Create {
363 description: String,
365 source: FeatureSource,
367 },
368 Continue,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
374#[serde(rename_all = "snake_case")]
375pub enum CommentOp {
376 Add,
378 Delete,
380 Keep,
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
386#[serde(rename_all = "snake_case")]
387pub enum PolicyList {
388 CommandsPrefix,
390 NetworkPrefix,
392}
393
394impl PolicyList {
395 #[must_use]
397 pub const fn as_str(self) -> &'static str {
398 match self {
399 Self::CommandsPrefix => "commands_prefix",
400 Self::NetworkPrefix => "network_prefix",
401 }
402 }
403
404 #[must_use]
406 pub const fn key(self) -> &'static str {
407 match self {
408 Self::CommandsPrefix => "commands.allow_prefix",
409 Self::NetworkPrefix => "sandbox.network_allow_prefix",
410 }
411 }
412
413 #[must_use]
415 pub fn of_key(key: &str) -> Option<Self> {
416 match key {
417 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
418 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
419 _ => None,
420 }
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
426#[serde(rename_all = "snake_case")]
427pub enum BatchVerdict {
428 Approve,
430 Reject,
432}
433
434#[derive(
440 Debug,
441 Clone,
442 Copy,
443 PartialEq,
444 Eq,
445 Hash,
446 PartialOrd,
447 Ord,
448 Serialize,
449 Deserialize,
450 schemars::JsonSchema,
451)]
452#[serde(rename_all = "snake_case")]
453pub enum ActionKind {
454 Skill,
456 RunTask,
458 RunFeature,
460 Converge,
462 Commit,
464 Approve,
466 Reject,
468 Accept,
470 Skip,
472 Nudge,
474 Rule,
476 Review,
478 IssueComment,
480 QuickChange,
482 KeepSetAside,
484 DropSetAside,
486 FixSetup,
488 Feature,
490 PullRequest,
492 BugFix,
494 BugConfirm,
496 BugRun,
498 BugKeep,
500 AllowAlways,
502 WidenPolicy,
504 DecideBatch,
506 OracleTests,
508 ApproveAndRun,
510 Comment,
512 CommentsSend,
514 RevisionUndo,
516 CodeNudge,
518 AcceptanceAt,
520 PresetAdd,
522 PlanThrough,
524 NewProject,
526 UndoNewProject,
528 CloneRepository,
530 OpenSample,
532 SampleChecks,
534 Command,
536}
537
538impl ActionKind {
539 pub const ALL: &'static [Self] = &[
541 Self::Skill,
542 Self::RunTask,
543 Self::RunFeature,
544 Self::Converge,
545 Self::Commit,
546 Self::Approve,
547 Self::Reject,
548 Self::Accept,
549 Self::Skip,
550 Self::Nudge,
551 Self::Rule,
552 Self::Review,
553 Self::IssueComment,
554 Self::QuickChange,
555 Self::KeepSetAside,
556 Self::DropSetAside,
557 Self::FixSetup,
558 Self::Feature,
559 Self::PullRequest,
560 Self::BugFix,
561 Self::BugConfirm,
562 Self::BugRun,
563 Self::BugKeep,
564 Self::AllowAlways,
565 Self::WidenPolicy,
566 Self::DecideBatch,
567 Self::OracleTests,
568 Self::ApproveAndRun,
569 Self::Comment,
570 Self::CommentsSend,
571 Self::RevisionUndo,
572 Self::CodeNudge,
573 Self::AcceptanceAt,
574 Self::PresetAdd,
575 Self::PlanThrough,
576 Self::NewProject,
577 Self::UndoNewProject,
578 Self::CloneRepository,
579 Self::OpenSample,
580 Self::SampleChecks,
581 Self::Command,
582 ];
583
584 #[must_use]
586 pub const fn as_str(self) -> &'static str {
587 match self {
588 Self::Skill => "skill",
589 Self::RunTask => "run_task",
590 Self::RunFeature => "run_feature",
591 Self::Converge => "converge",
592 Self::Commit => "commit",
593 Self::Approve => "approve",
594 Self::Reject => "reject",
595 Self::Accept => "accept",
596 Self::Skip => "skip",
597 Self::Nudge => "nudge",
598 Self::Rule => "rule",
599 Self::Review => "review",
600 Self::IssueComment => "issue_comment",
601 Self::QuickChange => "quick_change",
602 Self::KeepSetAside => "keep_set_aside",
603 Self::DropSetAside => "drop_set_aside",
604 Self::FixSetup => "fix_setup",
605 Self::Feature => "feature",
606 Self::PullRequest => "pull_request",
607 Self::BugFix => "bug_fix",
608 Self::BugConfirm => "bug_confirm",
609 Self::BugRun => "bug_run",
610 Self::BugKeep => "bug_keep",
611 Self::AllowAlways => "allow_always",
612 Self::WidenPolicy => "widen_policy",
613 Self::DecideBatch => "decide_batch",
614 Self::OracleTests => "oracle_tests",
615 Self::ApproveAndRun => "approve_and_run",
616 Self::Comment => "comment",
617 Self::CommentsSend => "comments_send",
618 Self::RevisionUndo => "revision_undo",
619 Self::CodeNudge => "code_nudge",
620 Self::AcceptanceAt => "acceptance_at",
621 Self::PresetAdd => "preset_add",
622 Self::PlanThrough => "plan_through",
623 Self::NewProject => "new_project",
624 Self::UndoNewProject => "undo_new_project",
625 Self::CloneRepository => "clone_repository",
626 Self::OpenSample => "open_sample",
627 Self::SampleChecks => "sample_checks",
628 Self::Command => "command",
629 }
630 }
631
632 #[must_use]
635 pub fn example(self) -> Option<Runnable> {
636 let task = || "T001".to_owned();
637 Some(match self {
638 Self::Skill => Runnable::Skill {
639 name: "plan".to_owned(),
640 arguments: String::new(),
641 },
642 Self::RunTask => Runnable::Run { task: Some(task()) },
643 Self::RunFeature => Runnable::Run { task: None },
644 Self::Converge => Runnable::Converge,
645 Self::Commit => Runnable::Commit {
646 paths: vec!["plan.md".to_owned()],
647 },
648 Self::Approve => Runnable::Approve {
649 task: task(),
650 call: "0f3c".to_owned(),
651 digest: "d1".to_owned(),
652 },
653 Self::Reject => Runnable::Reject {
654 task: task(),
655 call: "0f3c".to_owned(),
656 digest: "d1".to_owned(),
657 },
658 Self::Accept => Runnable::Accept { task: task() },
659 Self::Skip => Runnable::Skip { task: task() },
660 Self::Nudge => Runnable::Nudge { task: task() },
661 Self::Rule => Runnable::Rule { task: task() },
662 Self::Review => Runnable::Review {
663 ids: vec!["FR-001".to_owned()],
664 verdict: "approve".to_owned(),
665 },
666 Self::IssueComment => Runnable::IssueComment {
667 issue: 42,
668 assign: false,
669 },
670 Self::QuickChange => Runnable::QuickChange {
671 request: "a change".to_owned(),
672 checks: Vec::new(),
673 run: false,
674 },
675 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
676 Self::DropSetAside => Runnable::DropSetAside { task: task() },
677 Self::FixSetup => Runnable::FixSetup,
678 Self::Feature => Runnable::Feature,
679 Self::PullRequest => Runnable::PullRequest,
680 Self::BugFix => Runnable::BugFix {
681 symptom: "saving an empty title crashes".to_owned(),
682 expected: vec!["an empty title is refused".to_owned()],
683 unchanged: Vec::new(),
684 issue: None,
685 tests: Vec::new(),
686 candidate: None,
687 slug: None,
688 run: false,
689 },
690 Self::BugConfirm => Runnable::BugConfirm {
691 task: "007-bugfixes/T001".to_owned(),
692 digest: "sha256:0".to_owned(),
693 reject: false,
694 },
695 Self::BugRun => Runnable::BugRun {
696 slug: None,
697 task: Some("007-bugfixes/T001".to_owned()),
698 },
699 Self::BugKeep => Runnable::BugKeep {
700 task: "007-bugfixes/T001".to_owned(),
701 },
702 Self::AllowAlways => Runnable::AllowAlways {
703 task: task(),
704 call: "0f3c".to_owned(),
705 },
706 Self::WidenPolicy => Runnable::WidenPolicy {
707 key: PolicyList::CommandsPrefix,
708 prefix: vec!["npm".to_owned(), "run".to_owned()],
709 digest: "d1".to_owned(),
710 },
711 Self::DecideBatch => Runnable::DecideBatch {
712 run: "run-1-0a0b0c0d".to_owned(),
713 verdict: BatchVerdict::Approve,
714 calls: vec!["0f3c".to_owned()],
715 digest: "d1".to_owned(),
716 resume_asking: false,
717 },
718 Self::OracleTests => Runnable::OracleTests {
719 req: "US1".to_owned(),
720 op: crate::oracle::OracleOp::Write,
721 path: None,
722 scenario: None,
723 reading: None,
724 },
725 Self::ApproveAndRun => Runnable::ApproveAndRun {
726 mode: crate::oracle::RunMode::Ask,
727 grants: Vec::new(),
728 sheet: None,
729 },
730 Self::Comment => Runnable::Comment {
731 op: CommentOp::Delete,
732 anchor: None,
733 id: Some("c1".to_owned()),
734 },
735 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
736 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
737 Self::CodeNudge => Runnable::CodeNudge {
738 task: task(),
739 comments: vec![crate::oracle::CodeComment {
740 path: "src/lib.rs".to_owned(),
741 line: 1,
742 quote: "pub fn ok() {}".to_owned(),
743 text: "name it".to_owned(),
744 }],
745 },
746 Self::AcceptanceAt => Runnable::AcceptanceAt {
747 feature: "001-greet-function".to_owned(),
748 story: "US1".to_owned(),
749 task: "T003".to_owned(),
750 revoke: false,
751 },
752 Self::PresetAdd => Runnable::PresetAdd { update: false },
753 Self::PlanThrough => Runnable::PlanThrough {
754 from: PlanFrom::Create {
755 description: "Users can export a report as CSV".to_owned(),
756 source: FeatureSource::Description,
757 },
758 through: Through::Tasks,
759 },
760 Self::NewProject => Runnable::NewProject {
761 target: "/tmp/md2csv".to_owned(),
762 choices: crate::new_project::NewChoices {
763 idea: "a CLI that turns Markdown tables into CSV".to_owned(),
764 ..crate::new_project::NewChoices::default()
765 },
766 plan_digest: "sha256:0".to_owned(),
767 },
768 Self::UndoNewProject => Runnable::UndoNewProject {
769 commit: "1a2b3c4".to_owned(),
770 },
771 Self::CloneRepository => Runnable::CloneRepository {
772 repo: "octo/hello".to_owned(),
773 target: "/tmp/hello".to_owned(),
774 },
775 Self::OpenSample => Runnable::OpenSample { fresh: false },
776 Self::SampleChecks => Runnable::SampleChecks,
777 Self::Command => return None,
778 })
779 }
780}
781
782impl Runnable {
783 #[must_use]
785 pub fn examples() -> Vec<Self> {
786 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
787 }
788
789 #[must_use]
791 pub const fn kind(&self) -> ActionKind {
792 match self {
793 Self::Skill { .. } => ActionKind::Skill,
794 Self::Run { task: Some(_) } => ActionKind::RunTask,
795 Self::Run { task: None } => ActionKind::RunFeature,
796 Self::Converge => ActionKind::Converge,
797 Self::Commit { .. } => ActionKind::Commit,
798 Self::Approve { .. } => ActionKind::Approve,
799 Self::Reject { .. } => ActionKind::Reject,
800 Self::Accept { .. } => ActionKind::Accept,
801 Self::Skip { .. } => ActionKind::Skip,
802 Self::Nudge { .. } => ActionKind::Nudge,
803 Self::Rule { .. } => ActionKind::Rule,
804 Self::Review { .. } => ActionKind::Review,
805 Self::IssueComment { .. } => ActionKind::IssueComment,
806 Self::QuickChange { .. } => ActionKind::QuickChange,
807 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
808 Self::DropSetAside { .. } => ActionKind::DropSetAside,
809 Self::FixSetup => ActionKind::FixSetup,
810 Self::Feature => ActionKind::Feature,
811 Self::PullRequest => ActionKind::PullRequest,
812 Self::BugFix { .. } => ActionKind::BugFix,
813 Self::BugConfirm { .. } => ActionKind::BugConfirm,
814 Self::BugRun { .. } => ActionKind::BugRun,
815 Self::BugKeep { .. } => ActionKind::BugKeep,
816 Self::AllowAlways { .. } => ActionKind::AllowAlways,
817 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
818 Self::DecideBatch { .. } => ActionKind::DecideBatch,
819 Self::OracleTests { .. } => ActionKind::OracleTests,
820 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
821 Self::Comment { .. } => ActionKind::Comment,
822 Self::CommentsSend { .. } => ActionKind::CommentsSend,
823 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
824 Self::CodeNudge { .. } => ActionKind::CodeNudge,
825 Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
826 Self::PresetAdd { .. } => ActionKind::PresetAdd,
827 Self::PlanThrough { .. } => ActionKind::PlanThrough,
828 Self::NewProject { .. } => ActionKind::NewProject,
829 Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
830 Self::CloneRepository { .. } => ActionKind::CloneRepository,
831 Self::OpenSample { .. } => ActionKind::OpenSample,
832 Self::SampleChecks => ActionKind::SampleChecks,
833 }
834 }
835}
836
837#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
841pub struct ActionId {
842 pub kind: ActionKind,
844 pub subject: Option<String>,
847}
848
849#[must_use]
852pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
853 let Some(run) = run else {
854 let words: Vec<&str> = command
856 .split_whitespace()
857 .skip_while(|w| *w == "speccoder")
858 .take_while(|w| {
859 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
860 && w.chars()
861 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
862 })
863 .collect();
864 return ActionId {
865 kind: ActionKind::Command,
866 subject: (!words.is_empty()).then(|| words.join(" ")),
867 };
868 };
869 let subject = match run {
870 Runnable::Skill { name, .. } => Some(name.clone()),
871 Runnable::Run { task } => task.clone(),
872 Runnable::Approve { task, .. }
873 | Runnable::Reject { task, .. }
874 | Runnable::Accept { task }
875 | Runnable::Skip { task }
876 | Runnable::Nudge { task }
877 | Runnable::Rule { task }
878 | Runnable::KeepSetAside { task }
879 | Runnable::DropSetAside { task }
880 | Runnable::AllowAlways { task, .. }
881 | Runnable::CodeNudge { task, .. }
882 | Runnable::AcceptanceAt { task, .. } => Some(task.clone()),
883 Runnable::OracleTests { req, .. } => Some(req.clone()),
884 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
885 Runnable::Comment { id, .. } => id.clone(),
886 Runnable::RevisionUndo { turn } => turn.clone(),
887 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
888 Runnable::DecideBatch { run, .. } => Some(run.clone()),
889 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
890 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
891 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
892 Runnable::BugFix { slug, issue, .. } => {
893 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
894 }
895 Runnable::Review { ids, verdict } => Some(match ids.first() {
896 Some(id) => format!("{verdict}:{id}"),
897 None => verdict.clone(),
898 }),
899 Runnable::PlanThrough { from, through } => Some(match from {
900 PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
901 PlanFrom::Continue => format!("continue:{}", through.as_str()),
902 }),
903 Runnable::NewProject { target, .. } => Some(target.clone()),
904 Runnable::UndoNewProject { commit } => Some(commit.clone()),
905 Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
906 Runnable::Converge
907 | Runnable::OpenSample { .. }
908 | Runnable::SampleChecks
909 | Runnable::Commit { .. }
910 | Runnable::QuickChange { .. }
911 | Runnable::FixSetup
912 | Runnable::Feature
913 | Runnable::PullRequest
914 | Runnable::PresetAdd { .. }
915 | Runnable::CommentsSend { .. } => None,
916 };
917 ActionId {
918 kind: run.kind(),
919 subject,
920 }
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
927#[serde(rename_all = "snake_case")]
928pub enum SkillRole {
929 Create,
931 TowardTasks,
933 Revise,
935 Check,
937 Other,
939}
940
941#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
946#[serde(rename_all = "snake_case")]
947pub enum Via {
948 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
950 Terminal,
951 Web,
953 Window,
955 Acp,
957 Phone,
959 Ci,
961 Mcp,
963}
964
965impl Via {
966 #[must_use]
968 pub const fn as_str(self) -> &'static str {
969 match self {
970 Self::Terminal => "terminal",
971 Self::Web => "web",
972 Self::Window => "window",
973 Self::Acp => "acp",
974 Self::Phone => "phone",
975 Self::Ci => "ci",
976 Self::Mcp => "mcp",
977 }
978 }
979
980 #[must_use]
983 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
984 if let Some(client) = recorded.strip_prefix("editor ") {
985 return Some((Self::Acp, Some(client.to_owned())));
986 }
987 let via = match recorded {
988 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
989 "web" => Self::Web,
990 "window" => Self::Window,
991 "acp" | "editor" => Self::Acp,
992 "phone" => Self::Phone,
993 "ci" => Self::Ci,
994 "mcp" => Self::Mcp,
995 _ => return None,
996 };
997 Some((via, None))
998 }
999}
1000
1001impl std::fmt::Display for Via {
1002 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1003 f.write_str(self.as_str())
1004 }
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1009pub struct Action {
1010 pub group: String,
1012 pub label: String,
1014 pub why: String,
1016 pub command: String,
1018 pub run: Option<Runnable>,
1021 pub blocked: Option<String>,
1023 pub input: Option<String>,
1025 pub confirm: Option<String>,
1028 pub id: ActionId,
1030 pub role: Option<SkillRole>,
1033}
1034
1035impl Action {
1036 #[must_use]
1038 pub fn new(
1039 group: &str,
1040 label: impl Into<String>,
1041 why: impl Into<String>,
1042 command: impl Into<String>,
1043 ) -> Self {
1044 let command = command.into();
1045 Self {
1046 group: group.to_owned(),
1047 label: label.into(),
1048 why: why.into(),
1049 id: identity(None, &command),
1050 command,
1051 run: None,
1052 blocked: None,
1053 input: None,
1054 confirm: None,
1055 role: None,
1056 }
1057 }
1058
1059 #[must_use]
1061 pub fn with_run(mut self, run: Runnable) -> Self {
1062 self.id = identity(Some(&run), &self.command);
1063 self.run = Some(run);
1064 self
1065 }
1066
1067 #[must_use]
1069 pub const fn with_role(mut self, role: SkillRole) -> Self {
1070 self.role = Some(role);
1071 self
1072 }
1073
1074 #[must_use]
1076 pub const fn runnable(&self) -> bool {
1077 self.run.is_some() && self.blocked.is_none()
1078 }
1079}
1080
1081#[must_use]
1083pub fn primary(actions: &[Action]) -> Option<&Action> {
1084 actions.iter().find(|a| a.group == "next" && a.runnable())
1085}
1086
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1089#[serde(rename_all = "snake_case")]
1090pub enum StepState {
1091 Done,
1093 Now,
1095 Todo,
1097 Skipped,
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1105pub struct JourneyStep {
1106 pub name: String,
1108 pub state: StepState,
1110}
1111
1112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1114pub struct JourneyFacts {
1115 pub spec: bool,
1117 pub plan: bool,
1119 pub tasks: bool,
1121 pub uncommitted: bool,
1123 pub total: usize,
1125 pub finished: usize,
1127}
1128
1129#[must_use]
1133pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1134 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1135 let done = [
1136 ("spec", f.spec),
1137 ("plan", f.plan),
1138 ("tasks", f.tasks && f.total > 0),
1140 ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1141 ("run", f.total > 0 && f.finished == f.total),
1142 ("review", false),
1143 ];
1144 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1145 let now = done
1146 .iter()
1147 .enumerate()
1148 .position(|(i, (_, d))| !d && !passed(i))
1149 .unwrap_or(done.len());
1150 done.iter()
1151 .enumerate()
1152 .map(|(i, (name, d))| JourneyStep {
1153 name: (*name).to_owned(),
1154 state: if *d && i < now {
1155 StepState::Done
1156 } else if passed(i) && i < now {
1157 StepState::Skipped
1158 } else if i == now {
1159 StepState::Now
1160 } else {
1161 StepState::Todo
1162 },
1163 })
1164 .collect()
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use super::*;
1170
1171 #[test]
1172 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1173 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1175 .unwrap_or_else(|e| panic!("{e}"));
1176 let mut named: Vec<String> = schema["oneOf"]
1177 .as_array()
1178 .into_iter()
1179 .flatten()
1180 .filter_map(|v| v["const"].as_str().map(str::to_owned))
1181 .collect();
1182 named.sort();
1183 let mut listed: Vec<String> = ActionKind::ALL
1184 .iter()
1185 .map(|k| k.as_str().to_owned())
1186 .collect();
1187 listed.sort();
1188 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1189 for k in ActionKind::ALL {
1190 assert_eq!(
1191 serde_json::to_value(k).unwrap_or_default(),
1192 k.as_str(),
1193 "{k:?}"
1194 );
1195 if let Some(run) = k.example() {
1196 assert_eq!(run.kind(), *k);
1197 assert_eq!(identity(Some(&run), "").kind, *k);
1198 }
1199 }
1200 assert_eq!(
1201 Runnable::examples().len(),
1202 ActionKind::ALL.len() - 1,
1203 "every kind but `command` has a runnable"
1204 );
1205 }
1206
1207 #[test]
1208 fn identity_is_what_runs_never_how_it_is_worded() {
1209 let a = Action::new(
1210 "task",
1211 "accept T004",
1212 "tick it",
1213 "speccoder task accept T004",
1214 )
1215 .with_run(Runnable::Accept {
1216 task: "T004".to_owned(),
1217 });
1218 let mut b = a.clone();
1219 b.label = "x tick it anyway".to_owned();
1220 b.why = "x".to_owned();
1221 assert_eq!(a.id, b.id);
1222 assert_eq!(
1223 a.id,
1224 ActionId {
1225 kind: ActionKind::Accept,
1226 subject: Some("T004".to_owned())
1227 }
1228 );
1229 let id = |c: &str| identity(None, c).subject;
1230 assert_eq!(
1231 id("speccoder attest verify").as_deref(),
1232 Some("attest verify")
1233 );
1234 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1235 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1236 assert_eq!(
1237 id("speccoder spec specify \"…\"").as_deref(),
1238 Some("spec specify")
1239 );
1240 let review = |ids: &[&str], v: &str| {
1241 identity(
1242 Some(&Runnable::Review {
1243 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1244 verdict: v.to_owned(),
1245 }),
1246 "",
1247 )
1248 .subject
1249 };
1250 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1251 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1252 }
1253
1254 #[test]
1255 fn origins_read_back_from_every_value_ever_recorded() {
1256 for (old, via, client) in [
1257 ("cli", Via::Terminal, None),
1258 ("tui", Via::Terminal, None),
1259 ("dashboard", Via::Terminal, None),
1260 ("terminal", Via::Terminal, None),
1261 ("web", Via::Web, None),
1262 ("editor Zed", Via::Acp, Some("Zed")),
1263 ] {
1264 assert_eq!(
1265 Via::read(old),
1266 Some((via, client.map(str::to_owned))),
1267 "{old}"
1268 );
1269 }
1270 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1271 assert_eq!(parsed, Via::Terminal);
1272 assert_eq!(Via::Acp.as_str(), "acp");
1273 assert_eq!(Via::read("nobody"), None);
1274 }
1275
1276 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1277 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1278 }
1279
1280 #[test]
1281 fn the_journey_marks_what_is_done_and_what_is_now() {
1282 use StepState::{Done, Now, Todo};
1283 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1284 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1285 assert_eq!(
1286 states(JourneyFacts::default()),
1287 at([Now, Todo, Todo, Todo, Todo, Todo])
1288 );
1289 let planned = JourneyFacts {
1290 spec: true,
1291 plan: true,
1292 ..JourneyFacts::default()
1293 };
1294 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1295 let written = JourneyFacts {
1296 tasks: true,
1297 uncommitted: true,
1298 total: 3,
1299 ..planned
1300 };
1301 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1302 let running = JourneyFacts {
1303 uncommitted: false,
1304 finished: 1,
1305 ..written
1306 };
1307 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1308 let finished = JourneyFacts {
1309 finished: 3,
1310 ..running
1311 };
1312 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1313 let empty = JourneyFacts {
1315 tasks: true,
1316 ..planned
1317 };
1318 assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1319 }
1320
1321 #[test]
1322 fn a_task_list_without_a_spec_skips_to_the_run() {
1323 use StepState::{Now, Skipped, Todo};
1324 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1325 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1326 let only_tasks = JourneyFacts {
1327 tasks: true,
1328 total: 4,
1329 finished: 1,
1330 ..JourneyFacts::default()
1331 };
1332 assert_eq!(
1333 states(only_tasks),
1334 at([
1335 Skipped,
1336 Skipped,
1337 StepState::Done,
1338 StepState::Done,
1339 Now,
1340 Todo
1341 ])
1342 );
1343 let planned = JourneyFacts {
1345 plan: true,
1346 ..JourneyFacts::default()
1347 };
1348 assert_eq!(
1349 states(planned),
1350 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1351 );
1352 }
1353}