use crate::core::{DispatchMechanism, Harness, RunMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {
RecordRuns,
FillTranscripts,
DetectStrayWrites,
Grade { finalize: bool },
Aggregate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepCommand {
pub label: &'static str,
pub kind: StepKind,
pub skill_dir: Option<String>,
pub skill: Option<String>,
pub iteration: u32,
pub harness: Harness,
pub run_mode: RunMode,
pub subagents_dir: Option<String>,
pub workspace_dir: Option<String>,
}
#[derive(Debug, Clone)]
pub struct StepParams<'a> {
pub skill_dir: Option<&'a str>,
pub skill: Option<&'a str>,
pub iteration: u32,
pub harness: Harness,
pub run_mode: RunMode,
pub subagents_dir: Option<&'a str>,
pub workspace_dir: Option<&'a str>,
}
impl Default for StepParams<'_> {
fn default() -> Self {
Self {
skill_dir: None,
skill: None,
iteration: 0,
harness: Harness::ClaudeCode,
run_mode: RunMode::Interactive,
subagents_dir: None,
workspace_dir: None,
}
}
}
impl StepParams<'_> {
fn step(
&self,
label: &'static str,
kind: StepKind,
subagents_dir: Option<String>,
) -> StepCommand {
StepCommand {
label,
kind,
skill_dir: self.skill_dir.map(str::to_string),
skill: self.skill.map(str::to_string),
iteration: self.iteration,
harness: self.harness,
run_mode: self.run_mode,
subagents_dir,
workspace_dir: self.workspace_dir.map(str::to_string),
}
}
}
pub fn build_ingest_commands(p: &StepParams) -> Vec<StepCommand> {
let transcripts = match p.run_mode.mechanism() {
DispatchMechanism::InSession => p.subagents_dir.map(str::to_string),
DispatchMechanism::Cli => None,
};
vec![
p.step("record-runs", StepKind::RecordRuns, transcripts.clone()),
p.step("fill-transcripts", StepKind::FillTranscripts, transcripts),
p.step("detect-stray-writes", StepKind::DetectStrayWrites, None),
p.step("grade", StepKind::Grade { finalize: false }, None),
]
}
pub fn build_finalize_commands(p: &StepParams) -> Vec<StepCommand> {
vec![
p.step("grade --finalize", StepKind::Grade { finalize: true }, None),
p.step("aggregate", StepKind::Aggregate, None),
]
}
pub fn run_steps<E>(
steps: &[StepCommand],
mut run: impl FnMut(&StepCommand) -> Result<(), E>,
) -> Option<&'static str> {
for step in steps {
println!("\n── {} ──", step.label);
if run(step).is_err() {
return Some(step.label);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> StepParams<'static> {
StepParams {
skill_dir: Some("/skills"),
skill: Some("mr-review"),
iteration: 2,
subagents_dir: Some("/subagents"),
..Default::default()
}
}
#[test]
fn ingest_runs_record_fill_stray_grade_in_order() {
let steps = build_ingest_commands(¶ms());
assert_eq!(
steps.iter().map(|s| s.label).collect::<Vec<_>>(),
vec![
"record-runs",
"fill-transcripts",
"detect-stray-writes",
"grade"
]
);
assert_eq!(
steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
vec![
StepKind::RecordRuns,
StepKind::FillTranscripts,
StepKind::DetectStrayWrites,
StepKind::Grade { finalize: false },
]
);
for s in &steps {
assert_eq!(s.skill_dir.as_deref(), Some("/skills"));
assert_eq!(s.skill.as_deref(), Some("mr-review"));
assert_eq!(s.iteration, 2);
}
assert_eq!(steps[0].subagents_dir.as_deref(), Some("/subagents"));
assert_eq!(steps[1].subagents_dir.as_deref(), Some("/subagents"));
assert_eq!(steps[2].subagents_dir, None);
assert_eq!(steps[3].subagents_dir, None);
}
#[test]
fn ingest_omits_subagents_for_codex() {
let steps = build_ingest_commands(&StepParams {
skill_dir: Some("/skills"),
skill: Some("mr-review"),
iteration: 2,
harness: Harness::Codex,
run_mode: RunMode::Hybrid,
..Default::default()
});
assert_eq!(
steps.iter().map(|s| s.label).collect::<Vec<_>>(),
vec![
"record-runs",
"fill-transcripts",
"detect-stray-writes",
"grade"
]
);
assert!(steps.iter().all(|s| s.harness == Harness::Codex));
assert_eq!(steps[0].subagents_dir, None);
assert_eq!(steps[1].subagents_dir, None);
}
#[test]
fn ingest_omits_subagents_for_claude_hybrid() {
let steps = build_ingest_commands(&StepParams {
skill_dir: Some("/skills"),
skill: Some("mr-review"),
iteration: 2,
harness: Harness::ClaudeCode,
run_mode: RunMode::Hybrid,
subagents_dir: Some("/subagents"),
..Default::default()
});
assert!(steps.iter().all(|s| s.harness == Harness::ClaudeCode));
assert!(steps.iter().all(|s| s.run_mode == RunMode::Hybrid));
assert_eq!(steps[0].subagents_dir, None);
assert_eq!(steps[1].subagents_dir, None);
}
#[test]
fn ingest_keeps_subagents_for_claude_interactive() {
let steps = build_ingest_commands(&StepParams {
iteration: 2,
subagents_dir: Some("/subagents"),
..Default::default()
});
assert_eq!(steps[0].subagents_dir.as_deref(), Some("/subagents"));
assert_eq!(steps[1].subagents_dir.as_deref(), Some("/subagents"));
}
#[test]
fn finalize_runs_grade_finalize_then_aggregate() {
let steps = build_finalize_commands(&StepParams {
skill_dir: Some("/skills"),
skill: Some("mr-review"),
iteration: 2,
..Default::default()
});
assert_eq!(
steps.iter().map(|s| s.label).collect::<Vec<_>>(),
vec!["grade --finalize", "aggregate"]
);
assert_eq!(steps[0].kind, StepKind::Grade { finalize: true });
assert_eq!(steps[1].kind, StepKind::Aggregate);
}
fn synthetic(label: &'static str) -> StepCommand {
StepCommand {
label,
kind: StepKind::Aggregate,
skill_dir: None,
skill: None,
iteration: 0,
harness: Harness::ClaudeCode,
run_mode: RunMode::Interactive,
subagents_dir: None,
workspace_dir: None,
}
}
#[test]
fn run_steps_stops_at_first_failure() {
let steps = [synthetic("a"), synthetic("b"), synthetic("c")];
let mut ran: Vec<&str> = Vec::new();
let failed = run_steps(&steps, |step| {
ran.push(step.label);
if step.label == "b" { Err(()) } else { Ok(()) }
});
assert_eq!(ran, vec!["a", "b"]); assert_eq!(failed, Some("b"));
}
#[test]
fn run_steps_reports_no_failure_on_success() {
let steps = [synthetic("a"), synthetic("b")];
let failed = run_steps(&steps, |_| Ok::<(), ()>(()));
assert_eq!(failed, None);
}
#[test]
fn direct_skill_context_keeps_skill_dir_absent() {
let steps = build_ingest_commands(&StepParams {
skill: Some("/skills/mr-review"),
iteration: 1,
..Default::default()
});
assert!(steps.iter().all(|s| s.skill_dir.is_none()));
assert!(
steps
.iter()
.all(|s| s.skill.as_deref() == Some("/skills/mr-review"))
);
}
#[test]
fn inferred_seeded_context_keeps_skill_absent() {
let steps = build_finalize_commands(&StepParams {
skill_dir: Some("/skills"),
iteration: 1,
..Default::default()
});
assert!(steps.iter().all(|s| s.skill.as_deref().is_none()));
assert!(
steps
.iter()
.all(|s| s.skill_dir.as_deref() == Some("/skills"))
);
}
}