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    }
181}
182
183pub mod claude;
184pub mod codex;
185pub mod opencode;
186pub mod pi;
187
188pub use claude::ClaudeDriver;
189pub use codex::CodexDriver;
190pub use opencode::OpenCodeDriver;
191pub use pi::PiDriver;
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::prompt::stage_prompt;
197    use crate::stage::Stage;
198
199    #[test]
200    fn driver_for_returns_correct_names() {
201        assert_eq!(driver_for(AgentKind::Claude).name(), "Claude Code");
202        assert_eq!(driver_for(AgentKind::Codex).name(), "OpenAI Codex");
203        assert_eq!(driver_for(AgentKind::OpenCode).name(), "OpenCode");
204        assert_eq!(driver_for(AgentKind::Pi).name(), "Pi");
205    }
206
207    /// 37-02: the drivers reproduce the legacy adapter byte-for-byte (the shim
208    /// delegated to them, so this guards against future drift now that the
209    /// legacy surface is removed).
210    #[test]
211    fn drivers_reproduce_legacy_adapter_behavior() {
212        let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
213
214        // Claude: stream-json argv + byte-identical legacy prompt.
215        let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), "x", &[]);
216        assert_eq!(program, "claude");
217        assert!(
218            args.windows(2)
219                .any(|w| w[0] == "--input-format" && w[1] == "stream-json")
220        );
221        assert_eq!(
222            ClaudeDriver.render_prompt(&intent),
223            crate::prompt::render_claude_style(&intent)
224        );
225
226        // OpenCode: positional `run <prompt>` + byte-identical legacy prompt.
227        let (program, args) = OpenCodeDriver.build_command(PhaseId::new(7), "x", &[]);
228        assert_eq!(program, "opencode");
229        assert_eq!(args, ["run", "x"]);
230        assert_eq!(
231            OpenCodeDriver.render_prompt(&intent),
232            crate::prompt::render_claude_style(&intent)
233        );
234    }
235
236    /// 37-03: Codex/Pi drivers. Codex carries the verified non-interactive
237    /// approval flag BEFORE `exec`; Pi keeps the Phase-36 `-p --no-approve`
238    /// argv and renders the de-Claude-ified workflow prompt.
239    #[test]
240    fn codex_and_pi_drivers_reproduce_legacy_behavior() {
241        let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
242
243        let (program, args) = CodexDriver.build_command(PhaseId::new(7), "x", &[]);
244        assert_eq!(program, "codex");
245        assert_eq!(
246            &args[0..2],
247            ["-a", "never"],
248            "the global approval flag must precede `exec` (verified form): {args:?}"
249        );
250        assert!(args.contains(&"exec".to_string()));
251        assert!(
252            CodexDriver
253                .render_prompt(&intent)
254                .contains("execute-phase.md")
255        );
256        assert!(
257            !CodexDriver
258                .render_prompt(&intent)
259                .contains("/gsd-execute-phase")
260        );
261
262        let (program, args) = PiDriver.build_command(PhaseId::new(7), "x", &[]);
263        assert_eq!(program, "pi");
264        assert_eq!(args, ["-p", "--no-approve", "x"]);
265        assert!(PiDriver.render_prompt(&intent).contains("execute-phase.md"));
266        assert!(
267            !PiDriver
268                .render_prompt(&intent)
269                .contains("/gsd-execute-phase")
270        );
271    }
272
273    /// 37-04: every driver passes the shared conformance suite, and Codex
274    /// declares the Define/Plan interactivity requirement that replaces the
275    /// hardcoded Codex-Define check.
276    #[test]
277    fn every_driver_passes_the_conformance_suite() {
278        let drivers: [Box<dyn AgentDriver>; 4] = [
279            Box::new(ClaudeDriver),
280            Box::new(CodexDriver),
281            Box::new(OpenCodeDriver),
282            Box::new(PiDriver),
283        ];
284        for driver in &drivers {
285            let results = driver.test_contract();
286            assert!(
287                !results.is_empty(),
288                "{} has no conformance cases",
289                driver.name()
290            );
291            for result in &results {
292                assert!(
293                    result.passed,
294                    "{} failed conformance case {:?}",
295                    driver.name(),
296                    result.name
297                );
298            }
299        }
300    }
301
302    /// A deliberately-broken driver: empty render + empty program. The suite
303    /// must FAIL it — the negative control proving `test_contract` isn't
304    /// vacuous (code-review finding #7).
305    struct BrokenDriver;
306
307    impl AgentDriver for BrokenDriver {
308        fn name(&self) -> &'static str {
309            "broken"
310        }
311        fn render_prompt(&self, _intent: &crate::prompt::StageIntent) -> String {
312            String::new()
313        }
314        fn build_command(
315            &self,
316            _phase: PhaseId,
317            _prompt: &str,
318            _roots: &[PathBuf],
319        ) -> (&'static str, Vec<String>) {
320            ("", Vec::new())
321        }
322    }
323
324    #[test]
325    fn conformance_suite_fails_a_broken_driver() {
326        let results = BrokenDriver.test_contract();
327        assert!(
328            results.iter().any(|r| !r.passed),
329            "the conformance suite must fail a broken driver (empty render, empty program)"
330        );
331    }
332
333    /// The workflow renderer must preserve the per-stage contracts (code-review
334    /// findings #1-5): Validate verdict, Ship review gate, Define no-op, Plan
335    /// idempotency, and a per-driver workflow root.
336    #[test]
337    fn workflow_render_preserves_stage_contracts() {
338        use crate::prompt::StageIntent;
339        use crate::stage::Stage;
340
341        let codex = CodexDriver;
342
343        // Validate demands the verdict (finding #1).
344        let validate =
345            codex.render_prompt(&StageIntent::for_stage(Stage::Validate, PhaseId::new(7)));
346        assert!(validate.contains("\"verdict\": \"pass\""));
347        assert!(validate.contains("\"verdict\": \"gaps\""));
348
349        // Ship keeps the review gate (finding #2).
350        let ship = codex.render_prompt(&StageIntent::for_stage(Stage::Ship, PhaseId::new(7)));
351        assert!(ship.contains("Critical"));
352        assert!(ship.contains("review:"));
353
354        // Define is the D-14 no-op (finding #3).
355        let define = codex.render_prompt(&StageIntent::for_stage(Stage::Define, PhaseId::new(7)));
356        assert!(define.contains("must NOT run") || define.contains("do NOT run"));
357        assert!(!define.contains("discuss-phase.md"));
358
359        // Plan keeps the idempotency guard (finding #3).
360        let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
361        assert!(plan.contains("already exists"));
362
363        // Pi points at its own workflow root (finding #5).
364        let pi_code = PiDriver.render_prompt(&StageIntent::for_stage(Stage::Code, PhaseId::new(7)));
365        assert!(pi_code.contains("$HOME/.pi/agent/gsd-core/workflows"));
366        assert!(!pi_code.contains("$HOME/.codex/gsd-core"));
367    }
368
369    #[test]
370    fn codex_define_and_plan_require_an_existing_artifact() {
371        assert_eq!(
372            CodexDriver.interactivity_mode(crate::stage::Stage::Define),
373            InteractivityMode::RequiresExistingArtifact
374        );
375        assert_eq!(
376            CodexDriver.interactivity_mode(crate::stage::Stage::Plan),
377            InteractivityMode::RequiresExistingArtifact
378        );
379        assert_eq!(
380            CodexDriver.interactivity_mode(crate::stage::Stage::Code),
381            InteractivityMode::HeadlessSafe
382        );
383        assert_eq!(
384            ClaudeDriver.interactivity_mode(crate::stage::Stage::Define),
385            InteractivityMode::HeadlessSafe
386        );
387    }
388
389    /// The shared-prompt invariant is retired (999.31 / 37-01): Claude and
390    /// OpenCode still render byte-identical legacy text, but Codex now renders
391    /// a Codex-native instruction instead of the shared `/gsd-*` slash command.
392    #[test]
393    fn claude_and_opencode_stay_identical_but_codex_renders_native() {
394        let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
395        let claude = driver_for(AgentKind::Claude).render_prompt(&intent);
396        let opencode = driver_for(AgentKind::OpenCode).render_prompt(&intent);
397        let codex = driver_for(AgentKind::Codex).render_prompt(&intent);
398
399        // Claude/OpenCode: byte-identical legacy text (zero regression).
400        assert_eq!(
401            claude, opencode,
402            "Claude and OpenCode must stay byte-identical after the migration"
403        );
404        assert_eq!(
405            claude,
406            stage_prompt(Stage::Code, PhaseId::new(7)),
407            "Claude must render the legacy stage_prompt text byte-for-byte (CONTEXT D-01)"
408        );
409
410        // Codex: native, NOT the shared slash-command text (the dogfood fix).
411        assert_ne!(
412            codex, claude,
413            "Codex must no longer render the shared slash-command text"
414        );
415        // Negative control, precise: no GSD slash COMMAND may appear (the
416        // `gsd-core` workflow-directory path is legitimate and must not trip
417        // a naive `/gsd-` substring check).
418        for command in [
419            "/gsd-discuss-phase",
420            "/gsd-plan-phase",
421            "/gsd-execute-phase",
422            "/gsd-validate-phase",
423            "/gsd-ship",
424            "/gsd-code-review",
425            "/gsd-audit-fix",
426        ] {
427            assert!(
428                !codex.contains(command),
429                "Codex render must not carry {command}: {codex}"
430            );
431        }
432        // Positive oracle: the native instruction references the workflow path,
433        // carries the --auto token, and states the completion contract (so an
434        // empty or \"do nothing\" string cannot pass).
435        assert!(codex.contains("execute-phase.md"));
436        assert!(codex.contains("--auto"));
437        assert!(codex.contains("DEVFLOW_RESULT"));
438    }
439
440    /// The Phase 31 launch contract, asserted as one thing because getting
441    /// only the flags right is the documented way to half-implement it: the
442    /// transport is `stream-json` in BOTH directions, and the prompt is not a
443    /// positional argument at all.
444    #[test]
445    fn claude_launches_headless_stream_json_without_positional_prompt() {
446        let prompt = stage_prompt(Stage::Code, PhaseId::new(3));
447        let (program, args) =
448            driver_for(AgentKind::Claude).build_command(PhaseId::new(3), &prompt, &[]);
449        assert_eq!(program, "claude");
450        assert!(args.iter().any(|a| a == "-p"));
451        assert!(
452            args.windows(2)
453                .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
454            "the INPUT format is what moves the initial turn onto stdin; \
455             flipping only the output format leaves the CLI with no first \
456             turn and it stalls headless: {args:?}"
457        );
458        assert!(
459            args.windows(2)
460                .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
461            "the OUTPUT format is what makes the capture a JSONL event stream \
462             the Layer 1 stream parser can read: {args:?}"
463        );
464        assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
465        assert!(
466            !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
467            "no positional prompt: the initial user turn travels on stdin, \
468             written by the monitor: {args:?}"
469        );
470    }
471
472    #[test]
473    fn codex_wraps_prompt_in_exec_and_json() {
474        let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
475        let (program, args) =
476            driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
477        assert_eq!(program, "codex");
478        let joined = args.join(" ");
479        assert!(joined.contains("exec"));
480        assert!(joined.contains("--sandbox workspace-write"));
481        assert!(joined.contains("--json"));
482    }
483
484    #[test]
485    fn opencode_wraps_prompt_in_run() {
486        let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
487        let (program, args) =
488            driver_for(AgentKind::OpenCode).build_command(PhaseId::new(7), &prompt, &[]);
489        assert_eq!(program, "opencode");
490        assert_eq!(args, ["run", prompt.as_str()]);
491    }
492
493    /// 13-06 dogfood regression (Codex leg): linked-worktree git metadata
494    /// lives under the main repo's `.git/` — outside the workspace-write
495    /// sandbox — and Codex read-only-mounts the cwd's resolved git dir, so
496    /// BOTH the common `.git` and the worktree admin dir must be granted
497    /// (verified with `codex sandbox` probes). Without roots, no override.
498    #[test]
499    fn codex_grants_writable_roots_for_worktree_git_metadata() {
500        let prompt = stage_prompt(Stage::Code, PhaseId::new(7));
501        let roots = vec![
502            PathBuf::from("/repo/.git"),
503            PathBuf::from("/repo/.git/worktrees/phase-07"),
504        ];
505        let (_, args) =
506            driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &roots);
507        let joined = args.join(" ");
508        assert!(
509            joined.contains(
510                r#"-c sandbox_workspace_write.writable_roots=["/repo/.git","/repo/.git/worktrees/phase-07"]"#
511            ),
512            "codex must whitelist the common .git AND the worktree admin dir: {joined}"
513        );
514
515        let (_, args) = driver_for(AgentKind::Codex).build_command(PhaseId::new(7), &prompt, &[]);
516        assert!(
517            !args.join(" ").contains("writable_roots"),
518            "no override without an extra root"
519        );
520    }
521
522    /// 13-06 dogfood regression: signed commits fail inside the Codex
523    /// sandbox (no route to the operator's signing agent) — codex scopes an
524    /// unsigned-commit override to its own process tree via GIT_CONFIG_*
525    /// env; agents without a sandbox get no extra env.
526    #[test]
527    fn codex_disables_signing_via_env_others_do_not() {
528        let env = driver_for(AgentKind::Codex).environment();
529        assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into())));
530        assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into())));
531        assert!(driver_for(AgentKind::Claude).environment().is_empty());
532        assert!(driver_for(AgentKind::OpenCode).environment().is_empty());
533    }
534
535    /// D-13: `preflight`'s default body is `Ok(())` for every built-in
536    /// adapter — none of Claude/Codex/OpenCode override it in Phase 17 (no
537    /// reviewer-set storage exists yet in `state.rs`/`config.rs`, review
538    /// consensus #6).
539    #[test]
540    fn default_preflight_is_ok_for_built_in_adapters() {
541        let state = crate::state::State::new(
542            PhaseId::new(1),
543            AgentKind::Claude,
544            crate::mode::Mode::Auto,
545            PathBuf::from("/repo"),
546        );
547        assert!(driver_for(AgentKind::Claude).health(&state).is_ok());
548        assert!(driver_for(AgentKind::Codex).health(&state).is_ok());
549        assert!(driver_for(AgentKind::OpenCode).health(&state).is_ok());
550    }
551}