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 AgentKind::Hermes => Box::new(HermesDriver),
182 }
183}
184
185pub mod antigravity;
186pub mod claude;
187pub mod codex;
188pub mod hermes;
189pub mod opencode;
190pub mod pi;
191
192pub use antigravity::AntigravityDriver;
193pub use claude::ClaudeDriver;
194pub use codex::CodexDriver;
195pub use hermes::HermesDriver;
196pub use opencode::OpenCodeDriver;
197pub use pi::PiDriver;
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::prompt::stage_prompt;
203 use crate::stage::Stage;
204
205 #[test]
206 fn driver_for_returns_correct_names() {
207 assert_eq!(driver_for(AgentKind::Claude).name(), "Claude Code");
208 assert_eq!(driver_for(AgentKind::Codex).name(), "OpenAI Codex");
209 assert_eq!(driver_for(AgentKind::OpenCode).name(), "OpenCode");
210 assert_eq!(driver_for(AgentKind::Pi).name(), "Pi");
211 assert_eq!(driver_for(AgentKind::Antigravity).name(), "Antigravity");
212 assert_eq!(driver_for(AgentKind::Hermes).name(), "Hermes");
213 }
214
215 #[test]
219 fn drivers_reproduce_legacy_adapter_behavior() {
220 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
221
222 let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), "x", &[]);
224 assert_eq!(program, "claude");
225 assert!(
226 args.windows(2)
227 .any(|w| w[0] == "--input-format" && w[1] == "stream-json")
228 );
229 assert_eq!(
230 ClaudeDriver.render_prompt(&intent),
231 crate::prompt::render_claude_style(&intent)
232 );
233
234 let (program, args) = OpenCodeDriver.build_command(PhaseId::new(7), "x", &[]);
236 assert_eq!(program, "opencode");
237 assert_eq!(args, ["run", "x"]);
238 assert_eq!(
239 OpenCodeDriver.render_prompt(&intent),
240 crate::prompt::render_claude_style(&intent)
241 );
242 }
243
244 #[test]
248 fn codex_and_pi_drivers_reproduce_legacy_behavior() {
249 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
250
251 let (program, args) = CodexDriver.build_command(PhaseId::new(7), "x", &[]);
252 assert_eq!(program, "codex");
253 assert_eq!(
254 &args[0..2],
255 ["-a", "never"],
256 "the global approval flag must precede `exec` (verified form): {args:?}"
257 );
258 assert!(args.contains(&"exec".to_string()));
259 assert!(
260 CodexDriver
261 .render_prompt(&intent)
262 .contains("execute-phase.md")
263 );
264 assert!(
265 !CodexDriver
266 .render_prompt(&intent)
267 .contains("/gsd-execute-phase")
268 );
269
270 let (program, args) = PiDriver.build_command(PhaseId::new(7), "x", &[]);
271 assert_eq!(program, "pi");
272 assert_eq!(args, ["-p", "--no-approve", "x"]);
273 assert!(PiDriver.render_prompt(&intent).contains("execute-phase.md"));
274 assert!(
275 !PiDriver
276 .render_prompt(&intent)
277 .contains("/gsd-execute-phase")
278 );
279 }
280
281 #[test]
285 fn every_driver_passes_the_conformance_suite() {
286 let drivers: [Box<dyn AgentDriver>; 6] = [
287 Box::new(ClaudeDriver),
288 Box::new(CodexDriver),
289 Box::new(OpenCodeDriver),
290 Box::new(PiDriver),
291 Box::new(AntigravityDriver),
292 Box::new(HermesDriver),
293 ];
294 for driver in &drivers {
295 let results = driver.test_contract();
296 assert!(
297 !results.is_empty(),
298 "{} has no conformance cases",
299 driver.name()
300 );
301 for result in &results {
302 assert!(
303 result.passed,
304 "{} failed conformance case {:?}",
305 driver.name(),
306 result.name
307 );
308 }
309 }
310 }
311
312 #[test]
318 fn antigravity_conformance_enrollment() {
319 let drivers: [Box<dyn AgentDriver>; 5] = [
320 Box::new(ClaudeDriver),
321 Box::new(CodexDriver),
322 Box::new(OpenCodeDriver),
323 Box::new(PiDriver),
324 Box::new(AntigravityDriver),
325 ];
326 let antigravity = drivers
327 .iter()
328 .find(|d| d.name() == "Antigravity")
329 .expect("the Antigravity driver must be enrolled in the shared suite");
330 let results = antigravity.test_contract();
331 assert_eq!(
332 results.len(),
333 7,
334 "1 name + 5 per-stage DEVFLOW_RESULT prompts + 1 program"
335 );
336 for result in &results {
337 assert!(
338 result.passed,
339 "Antigravity failed conformance case {:?}",
340 result.name
341 );
342 }
343 for driver in &drivers {
345 assert!(
346 driver.test_contract().iter().all(|r| r.passed),
347 "{} must pass the shared conformance suite",
348 driver.name()
349 );
350 }
351 }
352
353 #[test]
357 fn hermes_conformance_enrollment() {
358 let drivers: [Box<dyn AgentDriver>; 6] = [
359 Box::new(ClaudeDriver),
360 Box::new(CodexDriver),
361 Box::new(OpenCodeDriver),
362 Box::new(PiDriver),
363 Box::new(AntigravityDriver),
364 Box::new(HermesDriver),
365 ];
366 let hermes = drivers
367 .iter()
368 .find(|d| d.name() == "Hermes")
369 .expect("the Hermes driver must be enrolled in the shared suite");
370 let results = hermes.test_contract();
371 assert_eq!(
372 results.len(),
373 7,
374 "1 name + 5 per-stage DEVFLOW_RESULT prompts + 1 program"
375 );
376 for result in &results {
377 assert!(
378 result.passed,
379 "Hermes failed conformance case {:?}",
380 result.name
381 );
382 }
383 for driver in &drivers {
385 assert!(
386 driver.test_contract().iter().all(|r| r.passed),
387 "{} must pass the shared conformance suite",
388 driver.name()
389 );
390 }
391 }
392
393 struct BrokenDriver;
397
398 impl AgentDriver for BrokenDriver {
399 fn name(&self) -> &'static str {
400 "broken"
401 }
402 fn render_prompt(&self, _intent: &crate::prompt::StageIntent) -> String {
403 String::new()
404 }
405 fn build_command(
406 &self,
407 _phase: PhaseId,
408 _prompt: &str,
409 _roots: &[PathBuf],
410 ) -> (&'static str, Vec<String>) {
411 ("", Vec::new())
412 }
413 }
414
415 #[test]
416 fn conformance_suite_fails_a_broken_driver() {
417 let results = BrokenDriver.test_contract();
418 assert!(
419 results.iter().any(|r| !r.passed),
420 "the conformance suite must fail a broken driver (empty render, empty program)"
421 );
422 }
423
424 #[test]
428 fn workflow_render_preserves_stage_contracts() {
429 use crate::prompt::StageIntent;
430 use crate::stage::Stage;
431
432 let codex = CodexDriver;
433
434 let validate =
436 codex.render_prompt(&StageIntent::for_stage(Stage::Validate, PhaseId::new(7)));
437 assert!(validate.contains("\"verdict\": \"pass\""));
438 assert!(validate.contains("\"verdict\": \"gaps\""));
439
440 let ship = codex.render_prompt(&StageIntent::for_stage(Stage::Ship, PhaseId::new(7)));
442 assert!(ship.contains("Critical"));
443 assert!(ship.contains("review:"));
444
445 let define = codex.render_prompt(&StageIntent::for_stage(Stage::Define, PhaseId::new(7)));
447 assert!(define.contains("must NOT run") || define.contains("do NOT run"));
448 assert!(!define.contains("discuss-phase.md"));
449
450 let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
452 assert!(plan.contains("already exists"));
453
454 let pi_code = PiDriver.render_prompt(&StageIntent::for_stage(Stage::Code, PhaseId::new(7)));
456 assert!(pi_code.contains("$HOME/.pi/agent/gsd-core/workflows"));
457 assert!(!pi_code.contains("$HOME/.codex/gsd-core"));
458 }
459
460 #[test]
461 fn codex_define_and_plan_require_an_existing_artifact() {
462 assert_eq!(
463 CodexDriver.interactivity_mode(crate::stage::Stage::Define),
464 InteractivityMode::RequiresExistingArtifact
465 );
466 assert_eq!(
467 CodexDriver.interactivity_mode(crate::stage::Stage::Plan),
468 InteractivityMode::RequiresExistingArtifact
469 );
470 assert_eq!(
471 CodexDriver.interactivity_mode(crate::stage::Stage::Code),
472 InteractivityMode::HeadlessSafe
473 );
474 assert_eq!(
475 ClaudeDriver.interactivity_mode(crate::stage::Stage::Define),
476 InteractivityMode::HeadlessSafe
477 );
478 }
479
480 #[test]
484 fn claude_and_opencode_stay_identical_but_codex_renders_native() {
485 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
486 let claude = driver_for(AgentKind::Claude).render_prompt(&intent);
487 let opencode = driver_for(AgentKind::OpenCode).render_prompt(&intent);
488 let codex = driver_for(AgentKind::Codex).render_prompt(&intent);
489
490 assert_eq!(
492 claude, opencode,
493 "Claude and OpenCode must stay byte-identical after the migration"
494 );
495 assert_eq!(
496 claude,
497 stage_prompt(Stage::Code, PhaseId::new(7)),
498 "Claude must render the legacy stage_prompt text byte-for-byte (CONTEXT D-01)"
499 );
500
501 assert_ne!(
503 codex, claude,
504 "Codex must no longer render the shared slash-command text"
505 );
506 for command in [
510 "/gsd-discuss-phase",
511 "/gsd-plan-phase",
512 "/gsd-execute-phase",
513 "/gsd-validate-phase",
514 "/gsd-ship",
515 "/gsd-code-review",
516 "/gsd-audit-fix",
517 ] {
518 assert!(
519 !codex.contains(command),
520 "Codex render must not carry {command}: {codex}"
521 );
522 }
523 assert!(codex.contains("execute-phase.md"));
527 assert!(codex.contains("--auto"));
528 assert!(codex.contains("DEVFLOW_RESULT"));
529 }
530
531 #[test]
536 fn claude_launches_headless_stream_json_without_positional_prompt() {
537 let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
538 let (program, args) =
539 driver_for(AgentKind::Claude).build_command(PhaseId::new(3), &prompt, &[]);
540 assert_eq!(program, "claude");
541 assert!(args.iter().any(|a| a == "-p"));
542 assert!(
543 args.windows(2)
544 .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
545 "the INPUT format is what moves the initial turn onto stdin; \
546 flipping only the output format leaves the CLI with no first \
547 turn and it stalls headless: {args:?}"
548 );
549 assert!(
550 args.windows(2)
551 .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
552 "the OUTPUT format is what makes the capture a JSONL event stream \
553 the Layer 1 stream parser can read: {args:?}"
554 );
555 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
556 assert!(
557 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
558 "no positional prompt: the initial user turn travels on stdin, \
559 written by the monitor: {args:?}"
560 );
561 }
562
563 #[test]
564 fn codex_wraps_prompt_in_exec_and_json() {
565 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
566 let (program, args) =
567 driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
568 assert_eq!(program, "codex");
569 let joined = args.join(" ");
570 assert!(joined.contains("exec"));
571 assert!(joined.contains("--sandbox workspace-write"));
572 assert!(joined.contains("--json"));
573 }
574
575 #[test]
576 fn opencode_wraps_prompt_in_run() {
577 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
578 let (program, args) =
579 driver_for(AgentKind::OpenCode).build_command(PhaseId::new(7), &prompt, &[]);
580 assert_eq!(program, "opencode");
581 assert_eq!(args, ["run", prompt.as_str()]);
582 }
583
584 #[test]
590 fn codex_grants_writable_roots_for_worktree_git_metadata() {
591 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
592 let roots = vec![
593 PathBuf::from("/repo/.git"),
594 PathBuf::from("/repo/.git/worktrees/phase-07"),
595 ];
596 let (_, args) =
597 driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &roots);
598 let joined = args.join(" ");
599 assert!(
600 joined.contains(
601 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
602 ),
603 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
604 );
605
606 let (_, args) = driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
607 assert!(
608 !args.join(" ").contains("writable_roots"),
609 "no override without an extra root"
610 );
611 }
612
613 #[test]
618 fn codex_disables_signing_via_env_others_do_not() {
619 let env = driver_for(AgentKind::Codex).environment();
620 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
621 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
622 assert!(driver_for(AgentKind::Claude).environment().is_empty());
623 assert!(driver_for(AgentKind::OpenCode).environment().is_empty());
624 }
625
626 #[test]
631 fn default_preflight_is_ok_for_built_in_adapters() {
632 let state = crate::state::State::new(
633 PhaseId::new(1),
634 AgentKind::Claude,
635 crate::mode::Mode::Auto,
636 PathBuf::from("/repo"),
637 );
638 assert!(driver_for(AgentKind::Claude).health(&state).is_ok());
639 assert!(driver_for(AgentKind::Codex).health(&state).is_ok());
640 assert!(driver_for(AgentKind::OpenCode).health(&state).is_ok());
641 }
642}