1use crate::phase_id::PhaseId;
9use crate::state::AgentKind;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub struct DriverCapabilities {
18 pub subagent_dispatch: bool,
23}
24
25#[derive(Debug, Clone, Default)]
28#[non_exhaustive]
29pub struct SandboxRequirements {}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ContractResult {
34 pub name: &'static str,
35 pub passed: bool,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum InteractivityMode {
42 HeadlessSafe,
44 RequiresExistingArtifact,
48 RequiresTypedSubagents,
50 InteractiveOnly,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum DriverHealth {
58 BinaryAbsent,
60 NotHeadlessCapable(String),
62 HeadlessCapable,
64}
65
66pub trait AgentDriver {
71 fn name(&self) -> &'static str;
73
74 fn capabilities(&self) -> DriverCapabilities {
76 DriverCapabilities::default()
77 }
78
79 fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
81
82 fn build_command(
84 &self,
85 phase: PhaseId,
86 prompt: &str,
87 extra_writable_roots: &[PathBuf],
88 ) -> (&'static str, Vec<String>);
89
90 fn parse_completion(&self, _output: &str) -> Option<crate::agent_result::AgentResult> {
93 None
94 }
95
96 fn health(&self, _state: &crate::state::State) -> Result<(), String> {
98 Ok(())
99 }
100
101 fn environment(&self) -> Vec<(String, String)> {
103 Vec::new()
104 }
105
106 fn sandbox_requirements(&self) -> SandboxRequirements {
108 SandboxRequirements::default()
109 }
110
111 fn discover(&self) -> Result<(), String> {
113 Ok(())
114 }
115
116 fn test_contract(&self) -> Vec<ContractResult> {
118 contract_checks(self)
119 }
120
121 fn interactivity_mode(&self, _stage: crate::stage::Stage) -> InteractivityMode {
123 InteractivityMode::HeadlessSafe
124 }
125
126 fn workflow_root(&self) -> String {
130 "$HOME/.codex/gsd-core/workflows".to_string()
131 }
132
133 fn health_classification(&self, state: &crate::state::State) -> DriverHealth {
136 match self.health(state) {
137 Ok(()) => DriverHealth::HeadlessCapable,
138 Err(reason) => DriverHealth::NotHeadlessCapable(reason),
139 }
140 }
141}
142
143fn contract_checks<D: AgentDriver + ?Sized>(driver: &D) -> Vec<ContractResult> {
147 let mut checks = vec![ContractResult {
148 name: "name is non-empty",
149 passed: !driver.name().is_empty(),
150 }];
151 for stage in [
152 crate::stage::Stage::Define,
153 crate::stage::Stage::Plan,
154 crate::stage::Stage::Code,
155 crate::stage::Stage::Validate,
156 crate::stage::Stage::Ship,
157 ] {
158 let intent = crate::prompt::StageIntent::for_stage(stage, PhaseId::new(1));
159 let prompt = driver.render_prompt(&intent);
160 checks.push(ContractResult {
161 name: "render_prompt states the completion contract",
162 passed: prompt.contains("DEVFLOW_RESULT"),
163 });
164 }
165 let (program, _args) = driver.build_command(PhaseId::new(1), "contract", &[]);
166 checks.push(ContractResult {
167 name: "build_command names a program",
168 passed: !program.is_empty(),
169 });
170 checks
171}
172
173pub fn driver_for(kind: AgentKind) -> Box<dyn AgentDriver> {
175 match kind {
176 AgentKind::Claude => Box::new(ClaudeDriver),
177 AgentKind::Codex => Box::new(CodexDriver),
178 AgentKind::OpenCode => Box::new(OpenCodeDriver),
179 AgentKind::Pi => Box::new(PiDriver),
180 AgentKind::Antigravity => Box::new(AntigravityDriver),
181 }
182}
183
184pub mod antigravity;
185pub mod claude;
186pub mod codex;
187pub mod opencode;
188pub mod pi;
189
190pub use antigravity::AntigravityDriver;
191pub use claude::ClaudeDriver;
192pub use codex::CodexDriver;
193pub use opencode::OpenCodeDriver;
194pub use pi::PiDriver;
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::prompt::stage_prompt;
200 use crate::stage::Stage;
201
202 #[test]
203 fn driver_for_returns_correct_names() {
204 assert_eq!(driver_for(AgentKind::Claude).name(), "Claude Code");
205 assert_eq!(driver_for(AgentKind::Codex).name(), "OpenAI Codex");
206 assert_eq!(driver_for(AgentKind::OpenCode).name(), "OpenCode");
207 assert_eq!(driver_for(AgentKind::Pi).name(), "Pi");
208 assert_eq!(driver_for(AgentKind::Antigravity).name(), "Antigravity");
209 }
210
211 #[test]
215 fn drivers_reproduce_legacy_adapter_behavior() {
216 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
217
218 let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), "x", &[]);
220 assert_eq!(program, "claude");
221 assert!(
222 args.windows(2)
223 .any(|w| w[0] == "--input-format" && w[1] == "stream-json")
224 );
225 assert_eq!(
226 ClaudeDriver.render_prompt(&intent),
227 crate::prompt::render_claude_style(&intent)
228 );
229
230 let (program, args) = OpenCodeDriver.build_command(PhaseId::new(7), "x", &[]);
232 assert_eq!(program, "opencode");
233 assert_eq!(args, ["run", "x"]);
234 assert_eq!(
235 OpenCodeDriver.render_prompt(&intent),
236 crate::prompt::render_claude_style(&intent)
237 );
238 }
239
240 #[test]
244 fn codex_and_pi_drivers_reproduce_legacy_behavior() {
245 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
246
247 let (program, args) = CodexDriver.build_command(PhaseId::new(7), "x", &[]);
248 assert_eq!(program, "codex");
249 assert_eq!(
250 &args[0..2],
251 ["-a", "never"],
252 "the global approval flag must precede `exec` (verified form): {args:?}"
253 );
254 assert!(args.contains(&"exec".to_string()));
255 assert!(
256 CodexDriver
257 .render_prompt(&intent)
258 .contains("execute-phase.md")
259 );
260 assert!(
261 !CodexDriver
262 .render_prompt(&intent)
263 .contains("/gsd-execute-phase")
264 );
265
266 let (program, args) = PiDriver.build_command(PhaseId::new(7), "x", &[]);
267 assert_eq!(program, "pi");
268 assert_eq!(args, ["-p", "--no-approve", "x"]);
269 assert!(PiDriver.render_prompt(&intent).contains("execute-phase.md"));
270 assert!(
271 !PiDriver
272 .render_prompt(&intent)
273 .contains("/gsd-execute-phase")
274 );
275 }
276
277 #[test]
281 fn every_driver_passes_the_conformance_suite() {
282 let drivers: [Box<dyn AgentDriver>; 5] = [
283 Box::new(ClaudeDriver),
284 Box::new(CodexDriver),
285 Box::new(OpenCodeDriver),
286 Box::new(PiDriver),
287 Box::new(AntigravityDriver),
288 ];
289 for driver in &drivers {
290 let results = driver.test_contract();
291 assert!(
292 !results.is_empty(),
293 "{} has no conformance cases",
294 driver.name()
295 );
296 for result in &results {
297 assert!(
298 result.passed,
299 "{} failed conformance case {:?}",
300 driver.name(),
301 result.name
302 );
303 }
304 }
305 }
306
307 #[test]
313 fn antigravity_conformance_enrollment() {
314 let drivers: [Box<dyn AgentDriver>; 5] = [
315 Box::new(ClaudeDriver),
316 Box::new(CodexDriver),
317 Box::new(OpenCodeDriver),
318 Box::new(PiDriver),
319 Box::new(AntigravityDriver),
320 ];
321 let antigravity = drivers
322 .iter()
323 .find(|d| d.name() == "Antigravity")
324 .expect("the Antigravity driver must be enrolled in the shared suite");
325 let results = antigravity.test_contract();
326 assert_eq!(
327 results.len(),
328 7,
329 "1 name + 5 per-stage DEVFLOW_RESULT prompts + 1 program"
330 );
331 for result in &results {
332 assert!(
333 result.passed,
334 "Antigravity failed conformance case {:?}",
335 result.name
336 );
337 }
338 for driver in &drivers {
340 assert!(
341 driver.test_contract().iter().all(|r| r.passed),
342 "{} must pass the shared conformance suite",
343 driver.name()
344 );
345 }
346 }
347
348 struct BrokenDriver;
352
353 impl AgentDriver for BrokenDriver {
354 fn name(&self) -> &'static str {
355 "broken"
356 }
357 fn render_prompt(&self, _intent: &crate::prompt::StageIntent) -> String {
358 String::new()
359 }
360 fn build_command(
361 &self,
362 _phase: PhaseId,
363 _prompt: &str,
364 _roots: &[PathBuf],
365 ) -> (&'static str, Vec<String>) {
366 ("", Vec::new())
367 }
368 }
369
370 #[test]
371 fn conformance_suite_fails_a_broken_driver() {
372 let results = BrokenDriver.test_contract();
373 assert!(
374 results.iter().any(|r| !r.passed),
375 "the conformance suite must fail a broken driver (empty render, empty program)"
376 );
377 }
378
379 #[test]
383 fn workflow_render_preserves_stage_contracts() {
384 use crate::prompt::StageIntent;
385 use crate::stage::Stage;
386
387 let codex = CodexDriver;
388
389 let validate =
391 codex.render_prompt(&StageIntent::for_stage(Stage::Validate, PhaseId::new(7)));
392 assert!(validate.contains("\"verdict\": \"pass\""));
393 assert!(validate.contains("\"verdict\": \"gaps\""));
394
395 let ship = codex.render_prompt(&StageIntent::for_stage(Stage::Ship, PhaseId::new(7)));
397 assert!(ship.contains("Critical"));
398 assert!(ship.contains("review:"));
399
400 let define = codex.render_prompt(&StageIntent::for_stage(Stage::Define, PhaseId::new(7)));
402 assert!(define.contains("must NOT run") || define.contains("do NOT run"));
403 assert!(!define.contains("discuss-phase.md"));
404
405 let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
407 assert!(plan.contains("already exists"));
408
409 let pi_code = PiDriver.render_prompt(&StageIntent::for_stage(Stage::Code, PhaseId::new(7)));
411 assert!(pi_code.contains("$HOME/.pi/agent/gsd-core/workflows"));
412 assert!(!pi_code.contains("$HOME/.codex/gsd-core"));
413 }
414
415 #[test]
416 fn codex_define_and_plan_require_an_existing_artifact() {
417 assert_eq!(
418 CodexDriver.interactivity_mode(crate::stage::Stage::Define),
419 InteractivityMode::RequiresExistingArtifact
420 );
421 assert_eq!(
422 CodexDriver.interactivity_mode(crate::stage::Stage::Plan),
423 InteractivityMode::RequiresExistingArtifact
424 );
425 assert_eq!(
426 CodexDriver.interactivity_mode(crate::stage::Stage::Code),
427 InteractivityMode::HeadlessSafe
428 );
429 assert_eq!(
430 ClaudeDriver.interactivity_mode(crate::stage::Stage::Define),
431 InteractivityMode::HeadlessSafe
432 );
433 }
434
435 #[test]
439 fn claude_and_opencode_stay_identical_but_codex_renders_native() {
440 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
441 let claude = driver_for(AgentKind::Claude).render_prompt(&intent);
442 let opencode = driver_for(AgentKind::OpenCode).render_prompt(&intent);
443 let codex = driver_for(AgentKind::Codex).render_prompt(&intent);
444
445 assert_eq!(
447 claude, opencode,
448 "Claude and OpenCode must stay byte-identical after the migration"
449 );
450 assert_eq!(
451 claude,
452 stage_prompt(Stage::Code, PhaseId::new(7)),
453 "Claude must render the legacy stage_prompt text byte-for-byte (CONTEXT D-01)"
454 );
455
456 assert_ne!(
458 codex, claude,
459 "Codex must no longer render the shared slash-command text"
460 );
461 for command in [
465 "/gsd-discuss-phase",
466 "/gsd-plan-phase",
467 "/gsd-execute-phase",
468 "/gsd-validate-phase",
469 "/gsd-ship",
470 "/gsd-code-review",
471 "/gsd-audit-fix",
472 ] {
473 assert!(
474 !codex.contains(command),
475 "Codex render must not carry {command}: {codex}"
476 );
477 }
478 assert!(codex.contains("execute-phase.md"));
482 assert!(codex.contains("--auto"));
483 assert!(codex.contains("DEVFLOW_RESULT"));
484 }
485
486 #[test]
491 fn claude_launches_headless_stream_json_without_positional_prompt() {
492 let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
493 let (program, args) =
494 driver_for(AgentKind::Claude).build_command(PhaseId::new(3), &prompt, &[]);
495 assert_eq!(program, "claude");
496 assert!(args.iter().any(|a| a == "-p"));
497 assert!(
498 args.windows(2)
499 .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
500 "the INPUT format is what moves the initial turn onto stdin; \
501 flipping only the output format leaves the CLI with no first \
502 turn and it stalls headless: {args:?}"
503 );
504 assert!(
505 args.windows(2)
506 .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
507 "the OUTPUT format is what makes the capture a JSONL event stream \
508 the Layer 1 stream parser can read: {args:?}"
509 );
510 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
511 assert!(
512 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
513 "no positional prompt: the initial user turn travels on stdin, \
514 written by the monitor: {args:?}"
515 );
516 }
517
518 #[test]
519 fn codex_wraps_prompt_in_exec_and_json() {
520 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
521 let (program, args) =
522 driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
523 assert_eq!(program, "codex");
524 let joined = args.join(" ");
525 assert!(joined.contains("exec"));
526 assert!(joined.contains("--sandbox workspace-write"));
527 assert!(joined.contains("--json"));
528 }
529
530 #[test]
531 fn opencode_wraps_prompt_in_run() {
532 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
533 let (program, args) =
534 driver_for(AgentKind::OpenCode).build_command(PhaseId::new(7), &prompt, &[]);
535 assert_eq!(program, "opencode");
536 assert_eq!(args, ["run", prompt.as_str()]);
537 }
538
539 #[test]
545 fn codex_grants_writable_roots_for_worktree_git_metadata() {
546 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
547 let roots = vec![
548 PathBuf::from("/repo/.git"),
549 PathBuf::from("/repo/.git/worktrees/phase-07"),
550 ];
551 let (_, args) =
552 driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &roots);
553 let joined = args.join(" ");
554 assert!(
555 joined.contains(
556 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
557 ),
558 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
559 );
560
561 let (_, args) = driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
562 assert!(
563 !args.join(" ").contains("writable_roots"),
564 "no override without an extra root"
565 );
566 }
567
568 #[test]
573 fn codex_disables_signing_via_env_others_do_not() {
574 let env = driver_for(AgentKind::Codex).environment();
575 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
576 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
577 assert!(driver_for(AgentKind::Claude).environment().is_empty());
578 assert!(driver_for(AgentKind::OpenCode).environment().is_empty());
579 }
580
581 #[test]
586 fn default_preflight_is_ok_for_built_in_adapters() {
587 let state = crate::state::State::new(
588 PhaseId::new(1),
589 AgentKind::Claude,
590 crate::mode::Mode::Auto,
591 PathBuf::from("/repo"),
592 );
593 assert!(driver_for(AgentKind::Claude).health(&state).is_ok());
594 assert!(driver_for(AgentKind::Codex).health(&state).is_ok());
595 assert!(driver_for(AgentKind::OpenCode).health(&state).is_ok());
596 }
597}