use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Runnable {
Skill {
name: String,
arguments: String,
},
Run {
task: Option<String>,
},
Converge,
Commit {
paths: Vec<String>,
},
Approve {
task: String,
call: String,
digest: String,
},
Reject {
task: String,
call: String,
digest: String,
},
Accept {
task: String,
},
Skip {
task: String,
},
Nudge {
task: String,
},
Rule {
task: String,
},
Review {
ids: Vec<String>,
verdict: String,
},
IssueComment {
issue: u64,
assign: bool,
},
QuickChange {
request: String,
checks: Vec<String>,
run: bool,
},
KeepSetAside {
task: String,
},
DropSetAside {
task: String,
},
FixSetup,
Feature,
PullRequest,
BugFix {
symptom: String,
expected: Vec<String>,
unchanged: Vec<String>,
issue: Option<u64>,
tests: Vec<String>,
candidate: Option<String>,
slug: Option<String>,
run: bool,
},
BugConfirm {
task: String,
digest: String,
reject: bool,
},
BugKeep {
task: String,
},
BugRun {
slug: Option<String>,
task: Option<String>,
},
AllowAlways {
task: String,
call: String,
},
WidenPolicy {
key: PolicyList,
prefix: Vec<String>,
digest: String,
},
DecideBatch {
run: String,
verdict: BatchVerdict,
calls: Vec<String>,
digest: String,
resume_asking: bool,
},
OracleTests {
req: String,
op: crate::oracle::OracleOp,
path: Option<String>,
scenario: Option<String>,
reading: Option<crate::oracle::Reading>,
},
ApproveAndRun {
mode: crate::oracle::RunMode,
grants: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sheet: Option<String>,
},
Comment {
op: CommentOp,
anchor: Option<crate::oracle::CommentAnchor>,
id: Option<String>,
},
CommentsSend {
ids: Vec<String>,
},
RevisionUndo {
turn: Option<String>,
},
CodeNudge {
task: String,
comments: Vec<crate::oracle::CodeComment>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommentOp {
Add,
Delete,
Keep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PolicyList {
CommandsPrefix,
NetworkPrefix,
}
impl PolicyList {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::CommandsPrefix => "commands_prefix",
Self::NetworkPrefix => "network_prefix",
}
}
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::CommandsPrefix => "commands.allow_prefix",
Self::NetworkPrefix => "sandbox.network_allow_prefix",
}
}
#[must_use]
pub fn of_key(key: &str) -> Option<Self> {
match key {
"commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
"sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BatchVerdict {
Approve,
Reject,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
Serialize,
Deserialize,
schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
Skill,
RunTask,
RunFeature,
Converge,
Commit,
Approve,
Reject,
Accept,
Skip,
Nudge,
Rule,
Review,
IssueComment,
QuickChange,
KeepSetAside,
DropSetAside,
FixSetup,
Feature,
PullRequest,
BugFix,
BugConfirm,
BugRun,
BugKeep,
AllowAlways,
WidenPolicy,
DecideBatch,
OracleTests,
ApproveAndRun,
Comment,
CommentsSend,
RevisionUndo,
CodeNudge,
Command,
}
impl ActionKind {
pub const ALL: &'static [Self] = &[
Self::Skill,
Self::RunTask,
Self::RunFeature,
Self::Converge,
Self::Commit,
Self::Approve,
Self::Reject,
Self::Accept,
Self::Skip,
Self::Nudge,
Self::Rule,
Self::Review,
Self::IssueComment,
Self::QuickChange,
Self::KeepSetAside,
Self::DropSetAside,
Self::FixSetup,
Self::Feature,
Self::PullRequest,
Self::BugFix,
Self::BugConfirm,
Self::BugRun,
Self::BugKeep,
Self::AllowAlways,
Self::WidenPolicy,
Self::DecideBatch,
Self::OracleTests,
Self::ApproveAndRun,
Self::Comment,
Self::CommentsSend,
Self::RevisionUndo,
Self::CodeNudge,
Self::Command,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Skill => "skill",
Self::RunTask => "run_task",
Self::RunFeature => "run_feature",
Self::Converge => "converge",
Self::Commit => "commit",
Self::Approve => "approve",
Self::Reject => "reject",
Self::Accept => "accept",
Self::Skip => "skip",
Self::Nudge => "nudge",
Self::Rule => "rule",
Self::Review => "review",
Self::IssueComment => "issue_comment",
Self::QuickChange => "quick_change",
Self::KeepSetAside => "keep_set_aside",
Self::DropSetAside => "drop_set_aside",
Self::FixSetup => "fix_setup",
Self::Feature => "feature",
Self::PullRequest => "pull_request",
Self::BugFix => "bug_fix",
Self::BugConfirm => "bug_confirm",
Self::BugRun => "bug_run",
Self::BugKeep => "bug_keep",
Self::AllowAlways => "allow_always",
Self::WidenPolicy => "widen_policy",
Self::DecideBatch => "decide_batch",
Self::OracleTests => "oracle_tests",
Self::ApproveAndRun => "approve_and_run",
Self::Comment => "comment",
Self::CommentsSend => "comments_send",
Self::RevisionUndo => "revision_undo",
Self::CodeNudge => "code_nudge",
Self::Command => "command",
}
}
#[must_use]
pub fn example(self) -> Option<Runnable> {
let task = || "T001".to_owned();
Some(match self {
Self::Skill => Runnable::Skill {
name: "plan".to_owned(),
arguments: String::new(),
},
Self::RunTask => Runnable::Run { task: Some(task()) },
Self::RunFeature => Runnable::Run { task: None },
Self::Converge => Runnable::Converge,
Self::Commit => Runnable::Commit {
paths: vec!["plan.md".to_owned()],
},
Self::Approve => Runnable::Approve {
task: task(),
call: "0f3c".to_owned(),
digest: "d1".to_owned(),
},
Self::Reject => Runnable::Reject {
task: task(),
call: "0f3c".to_owned(),
digest: "d1".to_owned(),
},
Self::Accept => Runnable::Accept { task: task() },
Self::Skip => Runnable::Skip { task: task() },
Self::Nudge => Runnable::Nudge { task: task() },
Self::Rule => Runnable::Rule { task: task() },
Self::Review => Runnable::Review {
ids: vec!["FR-001".to_owned()],
verdict: "approve".to_owned(),
},
Self::IssueComment => Runnable::IssueComment {
issue: 42,
assign: false,
},
Self::QuickChange => Runnable::QuickChange {
request: "a change".to_owned(),
checks: Vec::new(),
run: false,
},
Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
Self::DropSetAside => Runnable::DropSetAside { task: task() },
Self::FixSetup => Runnable::FixSetup,
Self::Feature => Runnable::Feature,
Self::PullRequest => Runnable::PullRequest,
Self::BugFix => Runnable::BugFix {
symptom: "saving an empty title crashes".to_owned(),
expected: vec!["an empty title is refused".to_owned()],
unchanged: Vec::new(),
issue: None,
tests: Vec::new(),
candidate: None,
slug: None,
run: false,
},
Self::BugConfirm => Runnable::BugConfirm {
task: "007-bugfixes/T001".to_owned(),
digest: "sha256:0".to_owned(),
reject: false,
},
Self::BugRun => Runnable::BugRun {
slug: None,
task: Some("007-bugfixes/T001".to_owned()),
},
Self::BugKeep => Runnable::BugKeep {
task: "007-bugfixes/T001".to_owned(),
},
Self::AllowAlways => Runnable::AllowAlways {
task: task(),
call: "0f3c".to_owned(),
},
Self::WidenPolicy => Runnable::WidenPolicy {
key: PolicyList::CommandsPrefix,
prefix: vec!["npm".to_owned(), "run".to_owned()],
digest: "d1".to_owned(),
},
Self::DecideBatch => Runnable::DecideBatch {
run: "run-1-0a0b0c0d".to_owned(),
verdict: BatchVerdict::Approve,
calls: vec!["0f3c".to_owned()],
digest: "d1".to_owned(),
resume_asking: false,
},
Self::OracleTests => Runnable::OracleTests {
req: "US1".to_owned(),
op: crate::oracle::OracleOp::Write,
path: None,
scenario: None,
reading: None,
},
Self::ApproveAndRun => Runnable::ApproveAndRun {
mode: crate::oracle::RunMode::Ask,
grants: Vec::new(),
sheet: None,
},
Self::Comment => Runnable::Comment {
op: CommentOp::Delete,
anchor: None,
id: Some("c1".to_owned()),
},
Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
Self::CodeNudge => Runnable::CodeNudge {
task: task(),
comments: vec![crate::oracle::CodeComment {
path: "src/lib.rs".to_owned(),
line: 1,
quote: "pub fn ok() {}".to_owned(),
text: "name it".to_owned(),
}],
},
Self::Command => return None,
})
}
}
impl Runnable {
#[must_use]
pub fn examples() -> Vec<Self> {
ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
}
#[must_use]
pub const fn kind(&self) -> ActionKind {
match self {
Self::Skill { .. } => ActionKind::Skill,
Self::Run { task: Some(_) } => ActionKind::RunTask,
Self::Run { task: None } => ActionKind::RunFeature,
Self::Converge => ActionKind::Converge,
Self::Commit { .. } => ActionKind::Commit,
Self::Approve { .. } => ActionKind::Approve,
Self::Reject { .. } => ActionKind::Reject,
Self::Accept { .. } => ActionKind::Accept,
Self::Skip { .. } => ActionKind::Skip,
Self::Nudge { .. } => ActionKind::Nudge,
Self::Rule { .. } => ActionKind::Rule,
Self::Review { .. } => ActionKind::Review,
Self::IssueComment { .. } => ActionKind::IssueComment,
Self::QuickChange { .. } => ActionKind::QuickChange,
Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
Self::DropSetAside { .. } => ActionKind::DropSetAside,
Self::FixSetup => ActionKind::FixSetup,
Self::Feature => ActionKind::Feature,
Self::PullRequest => ActionKind::PullRequest,
Self::BugFix { .. } => ActionKind::BugFix,
Self::BugConfirm { .. } => ActionKind::BugConfirm,
Self::BugRun { .. } => ActionKind::BugRun,
Self::BugKeep { .. } => ActionKind::BugKeep,
Self::AllowAlways { .. } => ActionKind::AllowAlways,
Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
Self::DecideBatch { .. } => ActionKind::DecideBatch,
Self::OracleTests { .. } => ActionKind::OracleTests,
Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
Self::Comment { .. } => ActionKind::Comment,
Self::CommentsSend { .. } => ActionKind::CommentsSend,
Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
Self::CodeNudge { .. } => ActionKind::CodeNudge,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ActionId {
pub kind: ActionKind,
pub subject: Option<String>,
}
#[must_use]
pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
let Some(run) = run else {
let words: Vec<&str> = command
.split_whitespace()
.skip_while(|w| *w == "speccoder")
.take_while(|w| {
w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& w.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
})
.collect();
return ActionId {
kind: ActionKind::Command,
subject: (!words.is_empty()).then(|| words.join(" ")),
};
};
let subject = match run {
Runnable::Skill { name, .. } => Some(name.clone()),
Runnable::Run { task } => task.clone(),
Runnable::Approve { task, .. }
| Runnable::Reject { task, .. }
| Runnable::Accept { task }
| Runnable::Skip { task }
| Runnable::Nudge { task }
| Runnable::Rule { task }
| Runnable::KeepSetAside { task }
| Runnable::DropSetAside { task }
| Runnable::AllowAlways { task, .. }
| Runnable::CodeNudge { task, .. } => Some(task.clone()),
Runnable::OracleTests { req, .. } => Some(req.clone()),
Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
Runnable::Comment { id, .. } => id.clone(),
Runnable::RevisionUndo { turn } => turn.clone(),
Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
Runnable::DecideBatch { run, .. } => Some(run.clone()),
Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
Runnable::BugFix { slug, issue, .. } => {
slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
}
Runnable::Review { ids, verdict } => Some(match ids.first() {
Some(id) => format!("{verdict}:{id}"),
None => verdict.clone(),
}),
Runnable::Converge
| Runnable::Commit { .. }
| Runnable::QuickChange { .. }
| Runnable::FixSetup
| Runnable::Feature
| Runnable::PullRequest
| Runnable::CommentsSend { .. } => None,
};
ActionId {
kind: run.kind(),
subject,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SkillRole {
Create,
TowardTasks,
Revise,
Check,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Via {
#[serde(alias = "cli", alias = "tui", alias = "dashboard")]
Terminal,
Web,
Window,
Acp,
Phone,
Ci,
Mcp,
}
impl Via {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Terminal => "terminal",
Self::Web => "web",
Self::Window => "window",
Self::Acp => "acp",
Self::Phone => "phone",
Self::Ci => "ci",
Self::Mcp => "mcp",
}
}
#[must_use]
pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
if let Some(client) = recorded.strip_prefix("editor ") {
return Some((Self::Acp, Some(client.to_owned())));
}
let via = match recorded {
"terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
"web" => Self::Web,
"window" => Self::Window,
"acp" | "editor" => Self::Acp,
"phone" => Self::Phone,
"ci" => Self::Ci,
"mcp" => Self::Mcp,
_ => return None,
};
Some((via, None))
}
}
impl std::fmt::Display for Via {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct Action {
pub group: String,
pub label: String,
pub why: String,
pub command: String,
pub run: Option<Runnable>,
pub blocked: Option<String>,
pub input: Option<String>,
pub confirm: Option<String>,
pub id: ActionId,
pub role: Option<SkillRole>,
}
impl Action {
#[must_use]
pub fn new(
group: &str,
label: impl Into<String>,
why: impl Into<String>,
command: impl Into<String>,
) -> Self {
let command = command.into();
Self {
group: group.to_owned(),
label: label.into(),
why: why.into(),
id: identity(None, &command),
command,
run: None,
blocked: None,
input: None,
confirm: None,
role: None,
}
}
#[must_use]
pub fn with_run(mut self, run: Runnable) -> Self {
self.id = identity(Some(&run), &self.command);
self.run = Some(run);
self
}
#[must_use]
pub const fn with_role(mut self, role: SkillRole) -> Self {
self.role = Some(role);
self
}
#[must_use]
pub const fn runnable(&self) -> bool {
self.run.is_some() && self.blocked.is_none()
}
}
#[must_use]
pub fn primary(actions: &[Action]) -> Option<&Action> {
actions.iter().find(|a| a.group == "next" && a.runnable())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StepState {
Done,
Now,
Todo,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct JourneyStep {
pub name: String,
pub state: StepState,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct JourneyFacts {
pub spec: bool,
pub plan: bool,
pub tasks: bool,
pub uncommitted: bool,
pub total: usize,
pub finished: usize,
}
#[must_use]
pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
let done = [
("spec", f.spec),
("plan", f.plan),
("tasks", f.tasks),
("commit", f.tasks && !f.uncommitted),
("run", f.total > 0 && f.finished == f.total),
("review", false),
];
let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
let now = done
.iter()
.enumerate()
.position(|(i, (_, d))| !d && !passed(i))
.unwrap_or(done.len());
done.iter()
.enumerate()
.map(|(i, (name, d))| JourneyStep {
name: (*name).to_owned(),
state: if *d && i < now {
StepState::Done
} else if passed(i) && i < now {
StepState::Skipped
} else if i == now {
StepState::Now
} else {
StepState::Todo
},
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
.unwrap_or_else(|e| panic!("{e}"));
let mut named: Vec<String> = schema["oneOf"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v["const"].as_str().map(str::to_owned))
.collect();
named.sort();
let mut listed: Vec<String> = ActionKind::ALL
.iter()
.map(|k| k.as_str().to_owned())
.collect();
listed.sort();
assert_eq!(named, listed, "ActionKind::ALL is not the enum");
for k in ActionKind::ALL {
assert_eq!(
serde_json::to_value(k).unwrap_or_default(),
k.as_str(),
"{k:?}"
);
if let Some(run) = k.example() {
assert_eq!(run.kind(), *k);
assert_eq!(identity(Some(&run), "").kind, *k);
}
}
assert_eq!(
Runnable::examples().len(),
ActionKind::ALL.len() - 1,
"every kind but `command` has a runnable"
);
}
#[test]
fn identity_is_what_runs_never_how_it_is_worded() {
let a = Action::new(
"task",
"accept T004",
"tick it",
"speccoder task accept T004",
)
.with_run(Runnable::Accept {
task: "T004".to_owned(),
});
let mut b = a.clone();
b.label = "x tick it anyway".to_owned();
b.why = "x".to_owned();
assert_eq!(a.id, b.id);
assert_eq!(
a.id,
ActionId {
kind: ActionKind::Accept,
subject: Some("T004".to_owned())
}
);
let id = |c: &str| identity(None, c).subject;
assert_eq!(
id("speccoder attest verify").as_deref(),
Some("attest verify")
);
assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
assert_eq!(
id("speccoder spec specify \"…\"").as_deref(),
Some("spec specify")
);
let review = |ids: &[&str], v: &str| {
identity(
Some(&Runnable::Review {
ids: ids.iter().map(|s| (*s).to_owned()).collect(),
verdict: v.to_owned(),
}),
"",
)
.subject
};
assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
}
#[test]
fn origins_read_back_from_every_value_ever_recorded() {
for (old, via, client) in [
("cli", Via::Terminal, None),
("tui", Via::Terminal, None),
("dashboard", Via::Terminal, None),
("terminal", Via::Terminal, None),
("web", Via::Web, None),
("editor Zed", Via::Acp, Some("Zed")),
] {
assert_eq!(
Via::read(old),
Some((via, client.map(str::to_owned))),
"{old}"
);
}
let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
assert_eq!(parsed, Via::Terminal);
assert_eq!(Via::Acp.as_str(), "acp");
assert_eq!(Via::read("nobody"), None);
}
fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
journey(f).into_iter().map(|s| (s.name, s.state)).collect()
}
#[test]
fn the_journey_marks_what_is_done_and_what_is_now() {
use StepState::{Done, Now, Todo};
let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
assert_eq!(
states(JourneyFacts::default()),
at([Now, Todo, Todo, Todo, Todo, Todo])
);
let planned = JourneyFacts {
spec: true,
plan: true,
..JourneyFacts::default()
};
assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
let written = JourneyFacts {
tasks: true,
uncommitted: true,
total: 3,
..planned
};
assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
let running = JourneyFacts {
uncommitted: false,
finished: 1,
..written
};
assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
let finished = JourneyFacts {
finished: 3,
..running
};
assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
}
#[test]
fn a_task_list_without_a_spec_skips_to_the_run() {
use StepState::{Now, Skipped, Todo};
let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
let only_tasks = JourneyFacts {
tasks: true,
total: 4,
finished: 1,
..JourneyFacts::default()
};
assert_eq!(
states(only_tasks),
at([
Skipped,
Skipped,
StepState::Done,
StepState::Done,
Now,
Todo
])
);
let planned = JourneyFacts {
plan: true,
..JourneyFacts::default()
};
assert_eq!(
states(planned),
at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
);
}
}