1use crate::phase_id::PhaseId;
9use crate::state::AgentKind;
10use std::path::PathBuf;
11
12pub trait AgentAdapter {
14 fn name(&self) -> &'static str;
16
17 fn exec_command(
29 &self,
30 phase: PhaseId,
31 prompt: &str,
32 extra_writable_roots: &[PathBuf],
33 ) -> (&'static str, Vec<String>);
34
35 fn extra_env(&self) -> Vec<(String, String)> {
42 Vec::new()
43 }
44
45 fn completion_signal_detected(&self, output: &str) -> bool;
47
48 fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
59 Ok(())
60 }
61
62 fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76#[non_exhaustive]
77pub struct DriverCapabilities {}
78
79#[derive(Debug, Clone, Default)]
82#[non_exhaustive]
83pub struct SandboxRequirements {}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ContractResult {
88 pub name: &'static str,
89 pub passed: bool,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum InteractivityMode {
96 HeadlessSafe,
98 RequiresExistingArtifact,
102 RequiresTypedSubagents,
104 InteractiveOnly,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum DriverHealth {
112 BinaryAbsent,
114 NotHeadlessCapable(String),
116 HeadlessCapable,
118}
119
120pub trait AgentDriver {
125 fn name(&self) -> &'static str;
127
128 fn capabilities(&self) -> DriverCapabilities {
130 DriverCapabilities::default()
131 }
132
133 fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
135
136 fn build_command(
138 &self,
139 phase: PhaseId,
140 prompt: &str,
141 extra_writable_roots: &[PathBuf],
142 ) -> (&'static str, Vec<String>);
143
144 fn parse_completion(&self, _output: &str) -> Option<crate::agent_result::AgentResult> {
147 None
148 }
149
150 fn health(&self, _state: &crate::state::State) -> Result<(), String> {
152 Ok(())
153 }
154
155 fn environment(&self) -> Vec<(String, String)> {
157 Vec::new()
158 }
159
160 fn sandbox_requirements(&self) -> SandboxRequirements {
162 SandboxRequirements::default()
163 }
164
165 fn discover(&self) -> Result<(), String> {
167 Ok(())
168 }
169
170 fn test_contract(&self) -> Vec<ContractResult> {
172 contract_checks(self)
173 }
174
175 fn interactivity_mode(&self, _stage: crate::stage::Stage) -> InteractivityMode {
177 InteractivityMode::HeadlessSafe
178 }
179
180 fn workflow_root(&self) -> String {
184 "$HOME/.codex/gsd-core/workflows".to_string()
185 }
186
187 fn health_classification(&self, state: &crate::state::State) -> DriverHealth {
190 match self.health(state) {
191 Ok(()) => DriverHealth::HeadlessCapable,
192 Err(reason) => DriverHealth::NotHeadlessCapable(reason),
193 }
194 }
195}
196
197fn contract_checks<D: AgentDriver + ?Sized>(driver: &D) -> Vec<ContractResult> {
201 let mut checks = vec![ContractResult {
202 name: "name is non-empty",
203 passed: !driver.name().is_empty(),
204 }];
205 for stage in [
206 crate::stage::Stage::Define,
207 crate::stage::Stage::Plan,
208 crate::stage::Stage::Code,
209 crate::stage::Stage::Validate,
210 crate::stage::Stage::Ship,
211 ] {
212 let intent = crate::prompt::StageIntent::for_stage(stage, PhaseId::new(1));
213 let prompt = driver.render_prompt(&intent);
214 checks.push(ContractResult {
215 name: "render_prompt states the completion contract",
216 passed: prompt.contains("DEVFLOW_RESULT"),
217 });
218 }
219 let (program, _args) = driver.build_command(PhaseId::new(1), "contract", &[]);
220 checks.push(ContractResult {
221 name: "build_command names a program",
222 passed: !program.is_empty(),
223 });
224 checks
225}
226
227struct DriverShim<D: AgentDriver>(D);
231
232impl<D: AgentDriver> AgentAdapter for DriverShim<D> {
233 fn name(&self) -> &'static str {
234 self.0.name()
235 }
236 fn exec_command(
237 &self,
238 phase: PhaseId,
239 prompt: &str,
240 extra_writable_roots: &[PathBuf],
241 ) -> (&'static str, Vec<String>) {
242 self.0.build_command(phase, prompt, extra_writable_roots)
243 }
244 fn extra_env(&self) -> Vec<(String, String)> {
245 self.0.environment()
246 }
247 fn completion_signal_detected(&self, _output: &str) -> bool {
248 false
252 }
253 fn preflight(&self, state: &crate::state::State) -> Result<(), String> {
254 self.0.health(state)
255 }
256 fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
257 self.0.render_prompt(intent)
258 }
259}
260
261pub fn adapter_for(kind: AgentKind) -> Box<dyn AgentAdapter> {
263 match kind {
264 AgentKind::Claude => Box::new(DriverShim(ClaudeDriver)),
265 AgentKind::Codex => Box::new(DriverShim(CodexDriver)),
266 AgentKind::OpenCode => Box::new(DriverShim(OpenCodeDriver)),
267 AgentKind::Pi => Box::new(DriverShim(PiDriver)),
268 }
269}
270
271pub mod claude;
272pub mod codex;
273pub mod opencode;
274pub mod pi;
275
276pub use claude::{ClaudeAgent, ClaudeDriver};
277pub use codex::{CodexAgent, CodexDriver};
278pub use opencode::{OpenCodeAgent, OpenCodeDriver};
279pub use pi::{PiAgent, PiDriver};
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::prompt::stage_prompt;
285 use crate::stage::Stage;
286
287 #[test]
288 fn adapter_for_returns_correct_names() {
289 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
290 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
291 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
292 assert_eq!(adapter_for(AgentKind::Pi).name(), "Pi");
293 }
294
295 #[test]
299 fn drivers_reproduce_legacy_adapter_behavior() {
300 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
301
302 let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), "x", &[]);
304 assert_eq!(program, "claude");
305 assert!(
306 args.windows(2)
307 .any(|w| w[0] == "--input-format" && w[1] == "stream-json")
308 );
309 assert_eq!(
310 ClaudeDriver.render_prompt(&intent),
311 crate::prompt::render_claude_style(&intent)
312 );
313
314 let (program, args) = OpenCodeDriver.build_command(PhaseId::new(7), "x", &[]);
316 assert_eq!(program, "opencode");
317 assert_eq!(args, ["run", "x"]);
318 assert_eq!(
319 OpenCodeDriver.render_prompt(&intent),
320 crate::prompt::render_claude_style(&intent)
321 );
322 }
323
324 #[test]
328 fn codex_and_pi_drivers_reproduce_legacy_behavior() {
329 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
330
331 let (program, args) = CodexDriver.build_command(PhaseId::new(7), "x", &[]);
332 assert_eq!(program, "codex");
333 assert_eq!(
334 &args[0..2],
335 ["-a", "never"],
336 "the global approval flag must precede `exec` (verified form): {args:?}"
337 );
338 assert!(args.contains(&"exec".to_string()));
339 assert!(
340 CodexDriver
341 .render_prompt(&intent)
342 .contains("execute-phase.md")
343 );
344 assert!(
345 !CodexDriver
346 .render_prompt(&intent)
347 .contains("/gsd-execute-phase")
348 );
349
350 let (program, args) = PiDriver.build_command(PhaseId::new(7), "x", &[]);
351 assert_eq!(program, "pi");
352 assert_eq!(args, ["-p", "--no-approve", "x"]);
353 assert!(PiDriver.render_prompt(&intent).contains("execute-phase.md"));
354 assert!(
355 !PiDriver
356 .render_prompt(&intent)
357 .contains("/gsd-execute-phase")
358 );
359 }
360
361 #[test]
365 fn every_driver_passes_the_conformance_suite() {
366 let drivers: [Box<dyn AgentDriver>; 4] = [
367 Box::new(ClaudeDriver),
368 Box::new(CodexDriver),
369 Box::new(OpenCodeDriver),
370 Box::new(PiDriver),
371 ];
372 for driver in &drivers {
373 let results = driver.test_contract();
374 assert!(
375 !results.is_empty(),
376 "{} has no conformance cases",
377 driver.name()
378 );
379 for result in &results {
380 assert!(
381 result.passed,
382 "{} failed conformance case {:?}",
383 driver.name(),
384 result.name
385 );
386 }
387 }
388 }
389
390 struct BrokenDriver;
394
395 impl AgentDriver for BrokenDriver {
396 fn name(&self) -> &'static str {
397 "broken"
398 }
399 fn render_prompt(&self, _intent: &crate::prompt::StageIntent) -> String {
400 String::new()
401 }
402 fn build_command(
403 &self,
404 _phase: PhaseId,
405 _prompt: &str,
406 _roots: &[PathBuf],
407 ) -> (&'static str, Vec<String>) {
408 ("", Vec::new())
409 }
410 }
411
412 #[test]
413 fn conformance_suite_fails_a_broken_driver() {
414 let results = BrokenDriver.test_contract();
415 assert!(
416 results.iter().any(|r| !r.passed),
417 "the conformance suite must fail a broken driver (empty render, empty program)"
418 );
419 }
420
421 #[test]
425 fn workflow_render_preserves_stage_contracts() {
426 use crate::prompt::StageIntent;
427 use crate::stage::Stage;
428
429 let codex = CodexDriver;
430
431 let validate =
433 codex.render_prompt(&StageIntent::for_stage(Stage::Validate, PhaseId::new(7)));
434 assert!(validate.contains("\"verdict\": \"pass\""));
435 assert!(validate.contains("\"verdict\": \"gaps\""));
436
437 let ship = codex.render_prompt(&StageIntent::for_stage(Stage::Ship, PhaseId::new(7)));
439 assert!(ship.contains("Critical"));
440 assert!(ship.contains("review:"));
441
442 let define = codex.render_prompt(&StageIntent::for_stage(Stage::Define, PhaseId::new(7)));
444 assert!(define.contains("must NOT run") || define.contains("do NOT run"));
445 assert!(!define.contains("discuss-phase.md"));
446
447 let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
449 assert!(plan.contains("already exists"));
450
451 let pi_code = PiDriver.render_prompt(&StageIntent::for_stage(Stage::Code, PhaseId::new(7)));
453 assert!(pi_code.contains("$HOME/.pi/agent/gsd-core/workflows"));
454 assert!(!pi_code.contains("$HOME/.codex/gsd-core"));
455 }
456
457 #[test]
458 fn codex_define_and_plan_require_an_existing_artifact() {
459 assert_eq!(
460 CodexDriver.interactivity_mode(crate::stage::Stage::Define),
461 InteractivityMode::RequiresExistingArtifact
462 );
463 assert_eq!(
464 CodexDriver.interactivity_mode(crate::stage::Stage::Plan),
465 InteractivityMode::RequiresExistingArtifact
466 );
467 assert_eq!(
468 CodexDriver.interactivity_mode(crate::stage::Stage::Code),
469 InteractivityMode::HeadlessSafe
470 );
471 assert_eq!(
472 ClaudeDriver.interactivity_mode(crate::stage::Stage::Define),
473 InteractivityMode::HeadlessSafe
474 );
475 }
476
477 #[test]
481 fn claude_and_opencode_stay_identical_but_codex_renders_native() {
482 let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
483 let claude = adapter_for(AgentKind::Claude).render_prompt(&intent);
484 let opencode = adapter_for(AgentKind::OpenCode).render_prompt(&intent);
485 let codex = adapter_for(AgentKind::Codex).render_prompt(&intent);
486
487 assert_eq!(
489 claude, opencode,
490 "Claude and OpenCode must stay byte-identical after the migration"
491 );
492 assert_eq!(
493 claude,
494 stage_prompt(Stage::Code, PhaseId::new(7)),
495 "Claude must render the legacy stage_prompt text byte-for-byte (CONTEXT D-01)"
496 );
497
498 assert_ne!(
500 codex, claude,
501 "Codex must no longer render the shared slash-command text"
502 );
503 for command in [
507 "/gsd-discuss-phase",
508 "/gsd-plan-phase",
509 "/gsd-execute-phase",
510 "/gsd-validate-phase",
511 "/gsd-ship",
512 "/gsd-code-review",
513 "/gsd-audit-fix",
514 ] {
515 assert!(
516 !codex.contains(command),
517 "Codex render must not carry {command}: {codex}"
518 );
519 }
520 assert!(codex.contains("execute-phase.md"));
524 assert!(codex.contains("--auto"));
525 assert!(codex.contains("DEVFLOW_RESULT"));
526 }
527
528 #[test]
533 fn claude_launches_headless_stream_json_without_positional_prompt() {
534 let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
535 let (program, args) =
536 adapter_for(AgentKind::Claude).exec_command(PhaseId::new(3), &prompt, &[]);
537 assert_eq!(program, "claude");
538 assert!(args.iter().any(|a| a == "-p"));
539 assert!(
540 args.windows(2)
541 .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
542 "the INPUT format is what moves the initial turn onto stdin; \
543 flipping only the output format leaves the CLI with no first \
544 turn and it stalls headless: {args:?}"
545 );
546 assert!(
547 args.windows(2)
548 .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
549 "the OUTPUT format is what makes the capture a JSONL event stream \
550 the Layer 1 stream parser can read: {args:?}"
551 );
552 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
553 assert!(
554 !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
555 "no positional prompt: the initial user turn travels on stdin, \
556 written by the monitor: {args:?}"
557 );
558 }
559
560 #[test]
561 fn codex_wraps_prompt_in_exec_and_json() {
562 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
563 let (program, args) =
564 adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &[]);
565 assert_eq!(program, "codex");
566 let joined = args.join(" ");
567 assert!(joined.contains("exec"));
568 assert!(joined.contains("--sandbox workspace-write"));
569 assert!(joined.contains("--json"));
570 }
571
572 #[test]
573 fn opencode_wraps_prompt_in_run() {
574 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
575 let (program, args) =
576 adapter_for(AgentKind::OpenCode).exec_command(PhaseId::new(7), &prompt, &[]);
577 assert_eq!(program, "opencode");
578 assert_eq!(args, ["run", prompt.as_str()]);
579 }
580
581 #[test]
587 fn codex_grants_writable_roots_for_worktree_git_metadata() {
588 let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
589 let roots = vec![
590 PathBuf::from("/repo/.git"),
591 PathBuf::from("/repo/.git/worktrees/phase-07"),
592 ];
593 let (_, args) =
594 adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &roots);
595 let joined = args.join(" ");
596 assert!(
597 joined.contains(
598 r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
599 ),
600 "codex must whitelist the common .git AND the worktree admin dir: {joined}"
601 );
602
603 let (_, args) = adapter_for(AgentKind::Codex).exec_command(PhaseId::new(7), &prompt, &[]);
604 assert!(
605 !args.join(" ").contains("writable_roots"),
606 "no override without an extra root"
607 );
608 }
609
610 #[test]
615 fn codex_disables_signing_via_env_others_do_not() {
616 let env = adapter_for(AgentKind::Codex).extra_env();
617 assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
618 assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
619 assert!(adapter_for(AgentKind::Claude).extra_env().is_empty());
620 assert!(adapter_for(AgentKind::OpenCode).extra_env().is_empty());
621 }
622
623 #[test]
628 fn default_preflight_is_ok_for_built_in_adapters() {
629 let state = crate::state::State::new(
630 PhaseId::new(1),
631 AgentKind::Claude,
632 crate::mode::Mode::Auto,
633 PathBuf::from("/repo"),
634 );
635 assert!(adapter_for(AgentKind::Claude).preflight(&state).is_ok());
636 assert!(adapter_for(AgentKind::Codex).preflight(&state).is_ok());
637 assert!(adapter_for(AgentKind::OpenCode).preflight(&state).is_ok());
638 }
639}