Skip to main content

devflow_core/agents/
mod.rs

1//! Agent driver contract and implementations.
2//!
3//! Each driver knows how to render a stage prompt for its agent and wrap it
4//! into the CLI's non-interactive launch command. Prompt RENDERING is
5//! driver-owned ([`AgentDriver::render_prompt`]): Claude/OpenCode render the
6//! legacy slash-command text, Codex renders a Codex-native instruction.
7
8use crate::phase_id::PhaseId;
9use crate::state::AgentKind;
10use std::path::PathBuf;
11
12/// Capabilities a driver declares, enumerated as-needed (999.31 D-01).
13/// `#[non_exhaustive]` + `Default` so adding a field never breaks an existing
14/// driver (CONTEXT D-12).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub struct DriverCapabilities {
18    /// Whether the agent has subagent/dispatch capability available in its
19    /// profile (e.g. Pi's `@bacnh85/pi-subagent` extension). Detected by probing
20    /// the installed CLI; `false` when absent or undetectable (fail-closed to
21    /// the baseline single-agent path).
22    pub subagent_dispatch: bool,
23}
24
25/// What a driver's sandbox needs from the launch environment. Reserved for
26/// 37-03 (Codex's writable-roots requirement).
27#[derive(Debug, Clone, Default)]
28#[non_exhaustive]
29pub struct SandboxRequirements {}
30
31/// One case from a driver's conformance contract (37-04).
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ContractResult {
34    pub name: &'static str,
35    pub passed: bool,
36}
37
38/// Per-stage interactivity requirement a driver declares (999.31 / 31c),
39/// replacing the hardcoded Codex-Define check.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum InteractivityMode {
42    /// The stage can run headless with no pre-existing artifact or operator.
43    HeadlessSafe,
44    /// The stage needs a pre-existing artifact (e.g. Codex's Define needs a
45    /// CONTEXT.md written ahead of time — it cannot run the interactive
46    /// discuss-phase interview headless).
47    RequiresExistingArtifact,
48    /// The stage needs typed-subagent dispatch (e.g. `multi_agent_v2`).
49    RequiresTypedSubagents,
50    /// The stage cannot run headless at all.
51    InteractiveOnly,
52}
53
54/// A driver's health classification, distinguishing "installed" from
55/// "headless-usable" (999.31 / 31c).
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum DriverHealth {
58    /// The binary is absent — `ensure_agent_binary` fails before health runs.
59    BinaryAbsent,
60    /// Installed but not headless-usable (e.g. no provider credential).
61    NotHeadlessCapable(String),
62    /// Ready to run headless.
63    HeadlessCapable,
64}
65
66/// The modular driver contract (999.31): each agent owns its prompt rendering,
67/// command building, completion parsing, and health/capability discovery —
68/// instead of that logic being scattered across `prompt.rs`, `agents/*.rs`,
69/// `agent_result.rs`, and `preflight.rs`.
70pub trait AgentDriver {
71    /// Human-readable driver name.
72    fn name(&self) -> &'static str;
73
74    /// Capabilities this driver declares (as-needed; default empty).
75    fn capabilities(&self) -> DriverCapabilities {
76        DriverCapabilities::default()
77    }
78
79    /// Render the stage prompt for this agent from a [`crate::prompt::StageIntent`].
80    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
81
82    /// Build the command and arguments to launch this agent headless.
83    fn build_command(
84        &self,
85        phase: PhaseId,
86        prompt: &str,
87        extra_writable_roots: &[PathBuf],
88    ) -> (&'static str, Vec<String>);
89
90    /// Parse this agent's completion signal out of captured output; `None` when
91    /// the transport is process-exit (no event stream to scan).
92    fn parse_completion(&self, _output: &str) -> Option<crate::agent_result::AgentResult> {
93        None
94    }
95
96    /// Driver-specific pre-launch health check.
97    fn health(&self, _state: &crate::state::State) -> Result<(), String> {
98        Ok(())
99    }
100
101    /// Extra environment variables for the agent process tree.
102    fn environment(&self) -> Vec<(String, String)> {
103        Vec::new()
104    }
105
106    /// Sandbox requirements for this agent's launch.
107    fn sandbox_requirements(&self) -> SandboxRequirements {
108        SandboxRequirements::default()
109    }
110
111    /// Discover capabilities from the installed CLI (e.g. `codex features list`).
112    fn discover(&self) -> Result<(), String> {
113        Ok(())
114    }
115
116    /// The conformance suite every driver must pass (37-04).
117    fn test_contract(&self) -> Vec<ContractResult> {
118        contract_checks(self)
119    }
120
121    /// The interactivity requirement for running `stage` headless.
122    fn interactivity_mode(&self, _stage: crate::stage::Stage) -> InteractivityMode {
123        InteractivityMode::HeadlessSafe
124    }
125
126    /// The directory holding this agent's GSD workflow files, used by the
127    /// workflow-reference renderer. Defaults to the Codex install; a driver
128    /// with a different install (e.g. Pi) overrides it.
129    fn workflow_root(&self) -> String {
130        "$HOME/.codex/gsd-core/workflows".to_string()
131    }
132
133    /// Classify this driver's health (the pass/fail [`AgentDriver::health`]
134    /// mapped onto the richer [`DriverHealth`]).
135    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
143/// Shared conformance checks every driver's `test_contract` runs (37-04).
144/// A future driver (Antigravity, Hermes) plugs in by passing these — the
145/// extensibility proof CONTEXT D-02 asks for.
146fn 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
173/// Return the driver for a configured agent kind.
174pub 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    /// 37-02: the drivers reproduce the legacy adapter byte-for-byte (the shim
212    /// delegated to them, so this guards against future drift now that the
213    /// legacy surface is removed).
214    #[test]
215    fn drivers_reproduce_legacy_adapter_behavior() {
216        let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
217
218        // Claude: stream-json argv + byte-identical legacy prompt.
219        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        // OpenCode: positional `run <prompt>` + byte-identical legacy prompt.
231        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    /// 37-03: Codex/Pi drivers. Codex carries the verified non-interactive
241    /// approval flag BEFORE `exec`; Pi keeps the Phase-36 `-p --no-approve`
242    /// argv and renders the de-Claude-ified workflow prompt.
243    #[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    /// 37-04: every driver passes the shared conformance suite, and Codex
278    /// declares the Define/Plan interactivity requirement that replaces the
279    /// hardcoded Codex-Define check.
280    #[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    /// F6: the Antigravity enrollment is PROVEN by a uniquely-named test —
308    /// the generic `conformance` filter matched two pre-existing tests and
309    /// would pass with zero Antigravity code, so the enrollment needed a name
310    /// that can only match this one. Asserts the hardcoded array is now 5
311    /// drivers AND that the Antigravity driver passes all 7 contract checks.
312    #[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        // The whole suite still passes with the 5th driver present.
339        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    /// A deliberately-broken driver: empty render + empty program. The suite
349    /// must FAIL it — the negative control proving `test_contract` isn't
350    /// vacuous (code-review finding #7).
351    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    /// The workflow renderer must preserve the per-stage contracts (code-review
380    /// findings #1-5): Validate verdict, Ship review gate, Define no-op, Plan
381    /// idempotency, and a per-driver workflow root.
382    #[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        // Validate demands the verdict (finding #1).
390        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        // Ship keeps the review gate (finding #2).
396        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        // Define is the D-14 no-op (finding #3).
401        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        // Plan keeps the idempotency guard (finding #3).
406        let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
407        assert!(plan.contains("already exists"));
408
409        // Pi points at its own workflow root (finding #5).
410        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    /// The shared-prompt invariant is retired (999.31 / 37-01): Claude and
436    /// OpenCode still render byte-identical legacy text, but Codex now renders
437    /// a Codex-native instruction instead of the shared `/gsd-*` slash command.
438    #[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        // Claude/OpenCode: byte-identical legacy text (zero regression).
446        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        // Codex: native, NOT the shared slash-command text (the dogfood fix).
457        assert_ne!(
458            codex, claude,
459            "Codex must no longer render the shared slash-command text"
460        );
461        // Negative control, precise: no GSD slash COMMAND may appear (the
462        // `gsd-core` workflow-directory path is legitimate and must not trip
463        // a naive `/gsd-` substring check).
464        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        // Positive oracle: the native instruction references the workflow path,
479        // carries the --auto token, and states the completion contract (so an
480        // empty or \"do nothing\" string cannot pass).
481        assert!(codex.contains("execute-phase.md"));
482        assert!(codex.contains("--auto"));
483        assert!(codex.contains("DEVFLOW_RESULT"));
484    }
485
486    /// The Phase 31 launch contract, asserted as one thing because getting
487    /// only the flags right is the documented way to half-implement it: the
488    /// transport is `stream-json` in BOTH directions, and the prompt is not a
489    /// positional argument at all.
490    #[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    /// 13-06 dogfood regression (Codex leg): linked-worktree git metadata
540    /// lives under the main repo's `.git/` — outside the workspace-write
541    /// sandbox — and Codex read-only-mounts the cwd's resolved git dir, so
542    /// BOTH the common `.git` and the worktree admin dir must be granted
543    /// (verified with `codex sandbox` probes). Without roots, no override.
544    #[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    /// 13-06 dogfood regression: signed commits fail inside the Codex
569    /// sandbox (no route to the operator's signing agent) — codex scopes an
570    /// unsigned-commit override to its own process tree via GIT_CONFIG_*
571    /// env; agents without a sandbox get no extra env.
572    #[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    /// D-13: `preflight`'s default body is `Ok(())` for every built-in
582    /// adapter — none of Claude/Codex/OpenCode override it in Phase 17 (no
583    /// reviewer-set storage exists yet in `state.rs`/`config.rs`, review
584    /// consensus #6).
585    #[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}