use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct NextStep {
pub command: String,
pub why: String,
}
#[derive(Serialize, schemars::JsonSchema)]
#[schemars(rename = "NextStep")]
struct Shown<'a> {
command: &'a str,
why: &'a str,
#[serde(flatten)]
how: crate::step::How,
}
impl Serialize for NextStep {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
Shown {
command: &self.command,
why: &self.why,
how: crate::step::step_of(&self.command, crate::step::Context::default()).how,
}
.serialize(serializer)
}
}
impl schemars::JsonSchema for NextStep {
fn schema_name() -> std::borrow::Cow<'static, str> {
"NextStep".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
Shown::json_schema(generator)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
TaskPassed {
task: String,
ready: usize,
},
FeatureDone,
NeedsApproval {
task: String,
},
Blocked {
task: String,
},
BudgetExhausted {
task: String,
},
Incomplete {
task: String,
},
VerificationFailed {
task: String,
},
SuspectedGaming {
task: String,
},
TestsOverStrict {
task: String,
},
AcceptanceWeak {
task: String,
},
NotDemonstrated {
task: String,
},
RunFailed {
task: Option<String>,
},
Interrupted {
task: String,
},
Running,
NothingReady,
Planning {
skills: Vec<String>,
},
ReportShown {
ready: usize,
open_approvals: usize,
stale: usize,
},
ApprovalsListed {
first: Option<String>,
},
Decided,
ChecklistsOpen,
ConfigError,
GitPrecondition,
ArchiveAwaiting {
change: String,
},
}
#[must_use]
pub fn next(outcome: &Outcome, feature: Option<&str>) -> Vec<NextStep> {
use Outcome as O;
let steps: Vec<(String, &str)> = match outcome {
O::NeedsApproval { task } => vec![
(
"speccoder report --approvals".to_owned(),
"see the call waiting and why it stopped",
),
(
format!("speccoder task approve {task}"),
"allow it; the attempt resumes on the next run",
),
(
format!("speccoder task reject {task} --reason \"…\""),
"refuse it; the model is told your reason",
),
("speccoder run".to_owned(), "continue the feature"),
],
O::Blocked { task } => vec![
(
format!("speccoder task done {task}"),
"you did it: tick it on your word (attested)",
),
(
format!("speccoder task hint {task} \"…\""),
"a hint, then a new attempt now",
),
(
format!("speccoder task skip {task} --reason \"…\""),
"pass it by; its dependents stay blocked",
),
],
O::Incomplete { task } => vec![
(
format!("speccoder task nudge {task} --note \"…\""),
"say what is missing, for the next attempt",
),
(format!("speccoder run --task {task}"), "try the task again"),
(
format!("speccoder task skip {task} --reason \"…\""),
"pass it by; its dependents stay blocked",
),
],
O::BudgetExhausted { task } => vec![
(
format!("speccoder task nudge {task} --note \"…\""),
"narrow the next attempt",
),
(format!("speccoder run --task {task}"), "try the task again"),
(
format!("speccoder task skip {task} --reason \"…\""),
"pass it by; its dependents stay blocked",
),
],
O::VerificationFailed { task } => vec![
(
"speccoder report".to_owned(),
"see what failed and the saved attempt",
),
(
format!("speccoder task nudge {task} --note \"…\""),
"tell the next attempt what to fix",
),
(format!("speccoder run --task {task}"), "try the task again"),
],
O::SuspectedGaming { task } => vec![
(
"speccoder report".to_owned(),
"review the saved attempt and the findings",
),
(format!("speccoder run --task {task}"), "try the task again"),
(
format!("speccoder task skip {task} --reason \"…\""),
"pass it by; its dependents stay blocked",
),
],
O::TestsOverStrict { task } => vec![
(
format!("speccoder task rule {task} --tests-right --reason \"…\""),
"the tests are right: the spec does not allow that alternative; the next run ticks the held attempt",
),
(
format!("speccoder run --task {task} --fresh-acceptance"),
"the tests are too strict: write them again and attempt anew",
),
(
format!("speccoder task accept {task} --reason \"…\""),
"tick it on your word (attested, not verified)",
),
],
O::AcceptanceWeak { task } => vec![
(
"speccoder report".to_owned(),
"see which wrong implementations the tests let through",
),
(
format!("speccoder run --task {task}"),
"try the task again with fresh acceptance tests",
),
(
format!("speccoder task accept {task} --reason \"…\""),
"tick it on your word (attested, not verified)",
),
],
O::NotDemonstrated { task } => vec![
(
"speccoder report".to_owned(),
"see why nothing was demonstrated",
),
(
format!("speccoder task nudge {task} --note \"…\""),
"tell the next attempt what is meant",
),
(
format!("speccoder task accept {task} --reason \"…\""),
"tick it on your word (attested)",
),
],
O::RunFailed { task } => {
let mut v = vec![(
"speccoder report --live".to_owned(),
"see where the run stopped",
)];
v.push(match task {
Some(t) => (format!("speccoder run --task {t}"), "try the task again"),
None => ("speccoder run".to_owned(), "try again"),
});
v
}
O::Interrupted { .. } => vec![(
"speccoder run".to_owned(),
"resume; finished calls will not repeat",
)],
O::Running => vec![
("speccoder tui".to_owned(), "watch the run"),
("speccoder report --live".to_owned(), "where it is now"),
],
O::TaskPassed { ready, .. } if *ready > 0 => vec![
("speccoder run".to_owned(), "continue with the ready tasks"),
("speccoder report".to_owned(), "every task and its evidence"),
],
O::TaskPassed { .. } | O::FeatureDone => vec![
("speccoder report".to_owned(), "every task and its evidence"),
(
"speccoder pr".to_owned(),
"hand the branch off as a draft pull request",
),
],
O::NothingReady => vec![
(
"speccoder report".to_owned(),
"see what blocks the open tasks",
),
(
"speccoder report --approvals".to_owned(),
"calls waiting for a person",
),
],
O::Planning { skills } => {
let mut steps: Vec<(String, &str)> = skills
.iter()
.map(|s| {
let why = match s.as_str() {
"plan" => "write the plan from the spec",
"tasks" => "break the plan into tasks",
"clarify" => "settle what the spec leaves open",
_ => "the next planning step",
};
(format!("speccoder spec {s}"), why)
})
.collect();
steps.push((
"speccoder run".to_owned(),
"after you review and commit what they write",
));
steps
}
O::ReportShown { open_approvals, .. } if *open_approvals > 0 => vec![
(
"speccoder report --approvals".to_owned(),
"calls waiting for a person",
),
("speccoder run".to_owned(), "continue the feature"),
],
O::ReportShown { stale, .. } if *stale > 0 => vec![(
"speccoder run --converge".to_owned(),
"the spec changed: re-check the code against it as it is now",
)],
O::ReportShown { ready, .. } if *ready > 0 => {
vec![("speccoder run".to_owned(), "continue with the ready tasks")]
}
O::ReportShown { .. } => vec![(
"speccoder pr".to_owned(),
"hand the branch off as a draft pull request",
)],
O::ApprovalsListed { first: Some(task) } => vec![
(
format!("speccoder task approve {task}"),
"allow it; the attempt resumes on the next run",
),
(
format!("speccoder task reject {task} --reason \"…\""),
"refuse it; the model is told your reason",
),
],
O::ApprovalsListed { first: None } | O::Decided => {
vec![("speccoder run".to_owned(), "continue the feature")]
}
O::ChecklistsOpen => vec![(
"speccoder run --ignore-checklists".to_owned(),
"run anyway; recorded on every commit",
)],
O::ConfigError => vec![
(
"speccoder init".to_owned(),
"write or repair speccoder.toml",
),
(
"speccoder verify".to_owned(),
"check the configured checks run",
),
],
O::GitPrecondition => vec![
("git status".to_owned(), "see what is uncommitted"),
("speccoder run".to_owned(), "run again once it is committed"),
],
O::ArchiveAwaiting { change } => vec![
(
format!("openspec show {change} --diff"),
"see what archiving changes in the main specs",
),
(
format!("speccoder spec archive {change} --yes"),
"archive it and commit the result",
),
],
};
steps
.into_iter()
.map(|(command, why)| NextStep {
command: match feature {
Some(f) if command.starts_with("speccoder ") => format!("{command} --feature {f}"),
_ => command,
},
why: why.to_owned(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn commands(o: &Outcome) -> Vec<String> {
next(o, None).into_iter().map(|s| s.command).collect()
}
#[test]
fn every_outcome_has_its_steps() {
let t = || "T004".to_owned();
let table: Vec<(Outcome, Vec<&str>)> = vec![
(
Outcome::NeedsApproval { task: t() },
vec![
"speccoder report --approvals",
"speccoder task approve T004",
"speccoder task reject T004 --reason \"…\"",
"speccoder run",
],
),
(
Outcome::Blocked { task: t() },
vec![
"speccoder task done T004",
"speccoder task hint T004 \"…\"",
"speccoder task skip T004 --reason \"…\"",
],
),
(
Outcome::Incomplete { task: t() },
vec![
"speccoder task nudge T004 --note \"…\"",
"speccoder run --task T004",
"speccoder task skip T004 --reason \"…\"",
],
),
(
Outcome::BudgetExhausted { task: t() },
vec![
"speccoder task nudge T004 --note \"…\"",
"speccoder run --task T004",
"speccoder task skip T004 --reason \"…\"",
],
),
(
Outcome::VerificationFailed { task: t() },
vec![
"speccoder report",
"speccoder task nudge T004 --note \"…\"",
"speccoder run --task T004",
],
),
(
Outcome::SuspectedGaming { task: t() },
vec![
"speccoder report",
"speccoder run --task T004",
"speccoder task skip T004 --reason \"…\"",
],
),
(
Outcome::NotDemonstrated { task: t() },
vec![
"speccoder report",
"speccoder task nudge T004 --note \"…\"",
"speccoder task accept T004 --reason \"…\"",
],
),
(
Outcome::RunFailed { task: Some(t()) },
vec!["speccoder report --live", "speccoder run --task T004"],
),
(
Outcome::RunFailed { task: None },
vec!["speccoder report --live", "speccoder run"],
),
(Outcome::Interrupted { task: t() }, vec!["speccoder run"]),
(
Outcome::Running,
vec!["speccoder tui", "speccoder report --live"],
),
(
Outcome::TaskPassed {
task: t(),
ready: 2,
},
vec!["speccoder run", "speccoder report"],
),
(
Outcome::TaskPassed {
task: t(),
ready: 0,
},
vec!["speccoder report", "speccoder pr"],
),
(
Outcome::FeatureDone,
vec!["speccoder report", "speccoder pr"],
),
(
Outcome::NothingReady,
vec!["speccoder report", "speccoder report --approvals"],
),
(
Outcome::ReportShown {
ready: 1,
open_approvals: 1,
stale: 1,
},
vec!["speccoder report --approvals", "speccoder run"],
),
(
Outcome::ReportShown {
ready: 1,
open_approvals: 0,
stale: 0,
},
vec!["speccoder run"],
),
(
Outcome::ReportShown {
ready: 0,
open_approvals: 0,
stale: 2,
},
vec!["speccoder run --converge"],
),
(
Outcome::ReportShown {
ready: 0,
open_approvals: 0,
stale: 0,
},
vec!["speccoder pr"],
),
(
Outcome::ApprovalsListed { first: Some(t()) },
vec![
"speccoder task approve T004",
"speccoder task reject T004 --reason \"…\"",
],
),
(
Outcome::ApprovalsListed { first: None },
vec!["speccoder run"],
),
(Outcome::Decided, vec!["speccoder run"]),
(
Outcome::ChecklistsOpen,
vec!["speccoder run --ignore-checklists"],
),
(
Outcome::ConfigError,
vec!["speccoder init", "speccoder verify"],
),
(
Outcome::GitPrecondition,
vec!["git status", "speccoder run"],
),
];
for (outcome, expected) in &table {
assert_eq!(commands(outcome), *expected, "{outcome:?}");
let steps = next(outcome, None);
assert!(!steps.is_empty());
assert_eq!(steps, next(outcome, None), "deterministic");
for s in &steps {
assert!(s.why.chars().count() <= 60, "{}", s.why);
assert!(
s.why.chars().next().is_some_and(char::is_lowercase),
"{}",
s.why
);
}
}
}
#[test]
fn the_feature_is_carried_to_speccoder_commands_only() {
let steps = next(&Outcome::GitPrecondition, Some("001-x"));
assert_eq!(steps[0].command, "git status");
assert_eq!(steps[1].command, "speccoder run --feature 001-x");
}
}