use crate::phase_id::PhaseId;
use crate::state::AgentKind;
use std::path::PathBuf;
pub trait AgentAdapter {
fn name(&self) -> &'static str;
fn exec_command(
&self,
phase: PhaseId,
prompt: &str,
extra_writable_roots: &[PathBuf],
) -> (&'static str, Vec<String>);
fn extra_env(&self) -> Vec<(String, String)> {
Vec::new()
}
fn completion_signal_detected(&self, output: &str) -> bool;
fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
Ok(())
}
fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct DriverCapabilities {}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct SandboxRequirements {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContractResult {
pub name: &'static str,
pub passed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractivityMode {
HeadlessSafe,
RequiresExistingArtifact,
RequiresTypedSubagents,
InteractiveOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriverHealth {
BinaryAbsent,
NotHeadlessCapable(String),
HeadlessCapable,
}
pub trait AgentDriver {
fn name(&self) -> &'static str;
fn capabilities(&self) -> DriverCapabilities {
DriverCapabilities::default()
}
fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
fn build_command(
&self,
phase: PhaseId,
prompt: &str,
extra_writable_roots: &[PathBuf],
) -> (&'static str, Vec<String>);
fn parse_completion(&self, _output: &str) -> Option<crate::agent_result::AgentResult> {
None
}
fn health(&self, _state: &crate::state::State) -> Result<(), String> {
Ok(())
}
fn environment(&self) -> Vec<(String, String)> {
Vec::new()
}
fn sandbox_requirements(&self) -> SandboxRequirements {
SandboxRequirements::default()
}
fn discover(&self) -> Result<(), String> {
Ok(())
}
fn test_contract(&self) -> Vec<ContractResult> {
contract_checks(self)
}
fn interactivity_mode(&self, _stage: crate::stage::Stage) -> InteractivityMode {
InteractivityMode::HeadlessSafe
}
fn workflow_root(&self) -> String {
"$HOME/.codex/gsd-core/workflows".to_string()
}
fn health_classification(&self, state: &crate::state::State) -> DriverHealth {
match self.health(state) {
Ok(()) => DriverHealth::HeadlessCapable,
Err(reason) => DriverHealth::NotHeadlessCapable(reason),
}
}
}
fn contract_checks<D: AgentDriver + ?Sized>(driver: &D) -> Vec<ContractResult> {
let mut checks = vec![ContractResult {
name: "name is non-empty",
passed: !driver.name().is_empty(),
}];
for stage in [
crate::stage::Stage::Define,
crate::stage::Stage::Plan,
crate::stage::Stage::Code,
crate::stage::Stage::Validate,
crate::stage::Stage::Ship,
] {
let intent = crate::prompt::StageIntent::for_stage(stage, PhaseId::new(1));
let prompt = driver.render_prompt(&intent);
checks.push(ContractResult {
name: "render_prompt states the completion contract",
passed: prompt.contains("DEVFLOW_RESULT"),
});
}
let (program, _args) = driver.build_command(PhaseId::new(1), "contract", &[]);
checks.push(ContractResult {
name: "build_command names a program",
passed: !program.is_empty(),
});
checks
}
struct DriverShim<D: AgentDriver>(D);
impl<D: AgentDriver> AgentAdapter for DriverShim<D> {
fn name(&self) -> &'static str {
self.0.name()
}
fn exec_command(
&self,
phase: PhaseId,
prompt: &str,
extra_writable_roots: &[PathBuf],
) -> (&'static str, Vec<String>) {
self.0.build_command(phase, prompt, extra_writable_roots)
}
fn extra_env(&self) -> Vec<(String, String)> {
self.0.environment()
}
fn completion_signal_detected(&self, _output: &str) -> bool {
false
}
fn preflight(&self, state: &crate::state::State) -> Result<(), String> {
self.0.health(state)
}
fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
self.0.render_prompt(intent)
}
}
pub fn adapter_for(kind: AgentKind) -> Box<dyn AgentAdapter> {
match kind {
AgentKind::Claude => Box::new(DriverShim(ClaudeDriver)),
AgentKind::Codex => Box::new(DriverShim(CodexDriver)),
AgentKind::OpenCode => Box::new(DriverShim(OpenCodeDriver)),
AgentKind::Pi => Box::new(DriverShim(PiDriver)),
}
}
pub mod claude;
pub mod codex;
pub mod opencode;
pub mod pi;
pub use claude::{ClaudeAgent, ClaudeDriver};
pub use codex::{CodexAgent, CodexDriver};
pub use opencode::{OpenCodeAgent, OpenCodeDriver};
pub use pi::{PiAgent, PiDriver};
#[cfg(test)]
mod tests {
use super::*;
use crate::prompt::stage_prompt;
use crate::stage::Stage;
#[test]
fn adapter_for_returns_correct_names() {
assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
assert_eq!(adapter_for(AgentKind::Pi).name(), "Pi");
}
#[test]
fn drivers_reproduce_legacy_adapter_behavior() {
let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), "x", &[]);
assert_eq!(program, "claude");
assert!(
args.windows(2)
.any(|w| w[0] == "--input-format" && w[1] == "stream-json")
);
assert_eq!(
ClaudeDriver.render_prompt(&intent),
crate::prompt::render_claude_style(&intent)
);
let (program, args) = OpenCodeDriver.build_command(PhaseId::new(7), "x", &[]);
assert_eq!(program, "opencode");
assert_eq!(args, ["run", "x"]);
assert_eq!(
OpenCodeDriver.render_prompt(&intent),
crate::prompt::render_claude_style(&intent)
);
}
#[test]
fn codex_and_pi_drivers_reproduce_legacy_behavior() {
let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
let (program, args) = CodexDriver.build_command(PhaseId::new(7), "x", &[]);
assert_eq!(program, "codex");
assert_eq!(
&args[0..2],
["-a", "never"],
"the global approval flag must precede `exec` (verified form): {args:?}"
);
assert!(args.contains(&"exec".to_string()));
assert!(
CodexDriver
.render_prompt(&intent)
.contains("execute-phase.md")
);
assert!(
!CodexDriver
.render_prompt(&intent)
.contains("/gsd-execute-phase")
);
let (program, args) = PiDriver.build_command(PhaseId::new(7), "x", &[]);
assert_eq!(program, "pi");
assert_eq!(args, ["-p", "--no-approve", "x"]);
assert!(PiDriver.render_prompt(&intent).contains("execute-phase.md"));
assert!(
!PiDriver
.render_prompt(&intent)
.contains("/gsd-execute-phase")
);
}
#[test]
fn every_driver_passes_the_conformance_suite() {
let drivers: [Box<dyn AgentDriver>; 4] = [
Box::new(ClaudeDriver),
Box::new(CodexDriver),
Box::new(OpenCodeDriver),
Box::new(PiDriver),
];
for driver in &drivers {
let results = driver.test_contract();
assert!(
!results.is_empty(),
"{} has no conformance cases",
driver.name()
);
for result in &results {
assert!(
result.passed,
"{} failed conformance case {:?}",
driver.name(),
result.name
);
}
}
}
struct BrokenDriver;
impl AgentDriver for BrokenDriver {
fn name(&self) -> &'static str {
"broken"
}
fn render_prompt(&self, _intent: &crate::prompt::StageIntent) -> String {
String::new()
}
fn build_command(
&self,
_phase: PhaseId,
_prompt: &str,
_roots: &[PathBuf],
) -> (&'static str, Vec<String>) {
("", Vec::new())
}
}
#[test]
fn conformance_suite_fails_a_broken_driver() {
let results = BrokenDriver.test_contract();
assert!(
results.iter().any(|r| !r.passed),
"the conformance suite must fail a broken driver (empty render, empty program)"
);
}
#[test]
fn workflow_render_preserves_stage_contracts() {
use crate::prompt::StageIntent;
use crate::stage::Stage;
let codex = CodexDriver;
let validate =
codex.render_prompt(&StageIntent::for_stage(Stage::Validate, PhaseId::new(7)));
assert!(validate.contains("\"verdict\": \"pass\""));
assert!(validate.contains("\"verdict\": \"gaps\""));
let ship = codex.render_prompt(&StageIntent::for_stage(Stage::Ship, PhaseId::new(7)));
assert!(ship.contains("Critical"));
assert!(ship.contains("review:"));
let define = codex.render_prompt(&StageIntent::for_stage(Stage::Define, PhaseId::new(7)));
assert!(define.contains("must NOT run") || define.contains("do NOT run"));
assert!(!define.contains("discuss-phase.md"));
let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
assert!(plan.contains("already exists"));
let pi_code = PiDriver.render_prompt(&StageIntent::for_stage(Stage::Code, PhaseId::new(7)));
assert!(pi_code.contains("$HOME/.pi/agent/gsd-core/workflows"));
assert!(!pi_code.contains("$HOME/.codex/gsd-core"));
}
#[test]
fn codex_define_and_plan_require_an_existing_artifact() {
assert_eq!(
CodexDriver.interactivity_mode(crate::stage::Stage::Define),
InteractivityMode::RequiresExistingArtifact
);
assert_eq!(
CodexDriver.interactivity_mode(crate::stage::Stage::Plan),
InteractivityMode::RequiresExistingArtifact
);
assert_eq!(
CodexDriver.interactivity_mode(crate::stage::Stage::Code),
InteractivityMode::HeadlessSafe
);
assert_eq!(
ClaudeDriver.interactivity_mode(crate::stage::Stage::Define),
InteractivityMode::HeadlessSafe
);
}
#[test]
fn claude_and_opencode_stay_identical_but_codex_renders_native() {
let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
let claude = adapter_for(AgentKind::Claude).render_prompt(&intent);
let opencode = adapter_for(AgentKind::OpenCode).render_prompt(&intent);
let codex = adapter_for(AgentKind::Codex).render_prompt(&intent);
assert_eq!(
claude, opencode,
"Claude and OpenCode must stay byte-identical after the migration"
);
assert_eq!(
claude,
stage_prompt(Stage::Code, PhaseId::new(7)),
"Claude must render the legacy stage_prompt text byte-for-byte (CONTEXT D-01)"
);
assert_ne!(
codex, claude,
"Codex must no longer render the shared slash-command text"
);
for command in [
"/gsd-discuss-phase",
"/gsd-plan-phase",
"/gsd-execute-phase",
"/gsd-validate-phase",
"/gsd-ship",
"/gsd-code-review",
"/gsd-audit-fix",
] {
assert!(
!codex.contains(command),
"Codex render must not carry {command}: {codex}"
);
}
assert!(codex.contains("execute-phase.md"));
assert!(codex.contains("--auto"));
assert!(codex.contains("DEVFLOW_RESULT"));
}
#[test]
fn claude_launches_headless_stream_json_without_positional_prompt() {
let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
let (program, args) =
adapter_for(AgentKind::Claude).exec_command(PhaseId::new(3), &prompt, &[]);
assert_eq!(program, "claude");
assert!(args.iter().any(|a| a == "-p"));
assert!(
args.windows(2)
.any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
"the INPUT format is what moves the initial turn onto stdin; \
flipping only the output format leaves the CLI with no first \
turn and it stalls headless: {args:?}"
);
assert!(
args.windows(2)
.any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
"the OUTPUT format is what makes the capture a JSONL event stream \
the Layer 1 stream parser can read: {args:?}"
);
assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
assert!(
!args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
"no positional prompt: the initial user turn travels on stdin, \
written by the monitor: {args:?}"
);
}
#[test]
fn codex_wraps_prompt_in_exec_and_json() {
let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
let (program, args) =
adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &[]);
assert_eq!(program, "codex");
let joined = args.join(" ");
assert!(joined.contains("exec"));
assert!(joined.contains("--sandbox workspace-write"));
assert!(joined.contains("--json"));
}
#[test]
fn opencode_wraps_prompt_in_run() {
let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
let (program, args) =
adapter_for(AgentKind::OpenCode).exec_command(PhaseId::new(7), &prompt, &[]);
assert_eq!(program, "opencode");
assert_eq!(args, ["run", prompt.as_str()]);
}
#[test]
fn codex_grants_writable_roots_for_worktree_git_metadata() {
let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
let roots = vec![
PathBuf::from("/repo/.git"),
PathBuf::from("/repo/.git/worktrees/phase-07"),
];
let (_, args) =
adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &roots);
let joined = args.join(" ");
assert!(
joined.contains(
r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
),
"codex must whitelist the common .git AND the worktree admin dir: {joined}"
);
let (_, args) = adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &[]);
assert!(
!args.join(" ").contains("writable_roots"),
"no override without an extra root"
);
}
#[test]
fn codex_disables_signing_via_env_others_do_not() {
let env = adapter_for(AgentKind::Codex).extra_env();
assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
}
#[test]
fn default_preflight_is_ok_for_built_in_adapters() {
let state = crate::state::State::new(
PhaseId::new(1),
AgentKind::Claude,
crate::mode::Mode::Auto,
PathBuf::from("/repo"),
);
assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
}
}