Skip to main content

devflow_core/agents/
mod.rs

1//! Agent adapter trait and implementations.
2//!
3//! Each adapter 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 ([`AgentAdapter::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/// Common behavior implemented by every supported coding-agent backend.
13pub trait AgentAdapter {
14    /// Human-readable adapter name.
15    fn name(&self) -> &'static str;
16
17    /// Build the command and arguments to launch this agent headless with the
18    /// given `prompt` for `phase`. Returns `(program, args)`.
19    ///
20    /// `extra_writable_roots` are directories OUTSIDE the agent's working
21    /// directory that its sandbox must still be allowed to write. Linked git
22    /// worktrees keep their git metadata under the main repo's `.git/` — and
23    /// Codex additionally read-only-mounts the cwd's resolved git dir, so
24    /// BOTH the common `.git` and the worktree admin dir
25    /// (`.git/worktrees/<name>`) must be granted explicitly (13-06 dogfood
26    /// finding, verified with `codex sandbox` probes). Adapters without a
27    /// sandbox ignore it.
28    fn exec_command(
29        &self,
30        phase: PhaseId,
31        prompt: &str,
32        extra_writable_roots: &[PathBuf],
33    ) -> (&'static str, Vec<String>);
34
35    /// Extra environment variables for the agent process tree. Codex uses
36    /// this to disable commit/tag signing inside its sandbox: the operator's
37    /// signing agent (ssh-agent/gpg-agent) is unreachable there, so signed
38    /// commits fail headless with a passphrase error (13-06 dogfood finding
39    /// — same rationale as the unsigned VersionBump tags). `GIT_CONFIG_*`
40    /// env scoping keeps the override out of every repo/global config.
41    fn extra_env(&self) -> Vec<(String, String)> {
42        Vec::new()
43    }
44
45    /// Detect an agent-specific completion signal in captured output.
46    fn completion_signal_detected(&self, output: &str) -> bool;
47
48    /// Adapter-specific pre-launch readiness check (D-13/D-14 adapter hook,
49    /// Phase 17c). The default is a no-op — most adapters have nothing extra
50    /// to check, mirroring [`Self::extra_env`]'s empty-default shape. The
51    /// `Err` variant is a human-readable failure reason that flows into the
52    /// preflight gate's context (`run_preflight` in `devflow-cli/src/main.rs`).
53    /// This is the trait surface Phase 18's Hermes adapter implements to
54    /// enforce a non-empty reviewer/receiver set — no built-in adapter
55    /// (Claude/Codex/OpenCode) overrides it in Phase 17 because no
56    /// reviewer-set storage exists yet in `state.rs`/`config.rs` (review
57    /// consensus #6).
58    fn preflight(&self, _state: &crate::state::State) -> Result<(), String> {
59        Ok(())
60    }
61
62    /// Render the stage prompt for this agent from a [`crate::prompt::StageIntent`].
63    ///
64    /// This is the de-Claude-ification seam (999.31 / 37-01): the intent carries
65    /// no agent syntax; each adapter turns it into its own instruction.
66    /// Claude and OpenCode render the legacy slash-command text byte-for-byte
67    /// (`crate::prompt::render_claude_style`); Codex renders a Codex-native
68    /// instruction with no `/gsd-*` string.
69    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
70}
71
72/// Capabilities a driver declares, enumerated as-needed (999.31 D-01).
73/// `#[non_exhaustive]` + `Default` so adding a field never breaks an existing
74/// driver (CONTEXT D-12).
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76#[non_exhaustive]
77pub struct DriverCapabilities {}
78
79/// What a driver's sandbox needs from the launch environment. Reserved for
80/// 37-03 (Codex's writable-roots requirement).
81#[derive(Debug, Clone, Default)]
82#[non_exhaustive]
83pub struct SandboxRequirements {}
84
85/// One case from a driver's conformance contract (37-04).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ContractResult {
88    pub name: &'static str,
89    pub passed: bool,
90}
91
92/// Per-stage interactivity requirement a driver declares (999.31 / 31c),
93/// replacing the hardcoded Codex-Define check.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum InteractivityMode {
96    /// The stage can run headless with no pre-existing artifact or operator.
97    HeadlessSafe,
98    /// The stage needs a pre-existing artifact (e.g. Codex's Define needs a
99    /// CONTEXT.md written ahead of time — it cannot run the interactive
100    /// discuss-phase interview headless).
101    RequiresExistingArtifact,
102    /// The stage needs typed-subagent dispatch (e.g. `multi_agent_v2`).
103    RequiresTypedSubagents,
104    /// The stage cannot run headless at all.
105    InteractiveOnly,
106}
107
108/// A driver's health classification, distinguishing "installed" from
109/// "headless-usable" (999.31 / 31c).
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum DriverHealth {
112    /// The binary is absent — `ensure_agent_binary` fails before health runs.
113    BinaryAbsent,
114    /// Installed but not headless-usable (e.g. no provider credential).
115    NotHeadlessCapable(String),
116    /// Ready to run headless.
117    HeadlessCapable,
118}
119
120/// The modular driver contract (999.31): each agent owns its prompt rendering,
121/// command building, completion parsing, and health/capability discovery —
122/// instead of that logic being scattered across `prompt.rs`, `agents/*.rs`,
123/// `agent_result.rs`, and `preflight.rs`.
124pub trait AgentDriver {
125    /// Human-readable driver name.
126    fn name(&self) -> &'static str;
127
128    /// Capabilities this driver declares (as-needed; default empty).
129    fn capabilities(&self) -> DriverCapabilities {
130        DriverCapabilities::default()
131    }
132
133    /// Render the stage prompt for this agent from a [`crate::prompt::StageIntent`].
134    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String;
135
136    /// Build the command and arguments to launch this agent headless.
137    fn build_command(
138        &self,
139        phase: PhaseId,
140        prompt: &str,
141        extra_writable_roots: &[PathBuf],
142    ) -> (&'static str, Vec<String>);
143
144    /// Parse this agent's completion signal out of captured output; `None` when
145    /// the transport is process-exit (no event stream to scan).
146    fn parse_completion(&self, _output: &str) -> Option<crate::agent_result::AgentResult> {
147        None
148    }
149
150    /// Driver-specific pre-launch health check.
151    fn health(&self, _state: &crate::state::State) -> Result<(), String> {
152        Ok(())
153    }
154
155    /// Extra environment variables for the agent process tree.
156    fn environment(&self) -> Vec<(String, String)> {
157        Vec::new()
158    }
159
160    /// Sandbox requirements for this agent's launch.
161    fn sandbox_requirements(&self) -> SandboxRequirements {
162        SandboxRequirements::default()
163    }
164
165    /// Discover capabilities from the installed CLI (e.g. `codex features list`).
166    fn discover(&self) -> Result<(), String> {
167        Ok(())
168    }
169
170    /// The conformance suite every driver must pass (37-04).
171    fn test_contract(&self) -> Vec<ContractResult> {
172        contract_checks(self)
173    }
174
175    /// The interactivity requirement for running `stage` headless.
176    fn interactivity_mode(&self, _stage: crate::stage::Stage) -> InteractivityMode {
177        InteractivityMode::HeadlessSafe
178    }
179
180    /// The directory holding this agent's GSD workflow files, used by the
181    /// workflow-reference renderer. Defaults to the Codex install; a driver
182    /// with a different install (e.g. Pi) overrides it.
183    fn workflow_root(&self) -> String {
184        "$HOME/.codex/gsd-core/workflows".to_string()
185    }
186
187    /// Classify this driver's health (the pass/fail [`AgentDriver::health`]
188    /// mapped onto the richer [`DriverHealth`]).
189    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
197/// Shared conformance checks every driver's `test_contract` runs (37-04).
198/// A future driver (Antigravity, Hermes) plugs in by passing these — the
199/// extensibility proof CONTEXT D-02 asks for.
200fn 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
227/// Compatibility shim (D-11 removal point): exposes an [`AgentDriver`] through
228/// the legacy [`AgentAdapter`] surface so every caller keeps compiling until
229/// 37-04 migrates them and removes `AgentAdapter`.
230struct 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        // Every current driver is process-exit transport (the monitor detects
249        // exit via kill -0); none emits an in-stream completion signal. This
250        // matches the legacy adapters' behavior byte-for-byte.
251        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
261/// Return an adapter for a configured agent kind.
262pub 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    /// 37-02: the drivers reproduce the legacy adapter byte-for-byte (the shim
296    /// delegates to them, so this guards against future drift when the
297    /// `AgentAdapter` face is removed in 37-04).
298    #[test]
299    fn drivers_reproduce_legacy_adapter_behavior() {
300        let intent = crate::prompt::StageIntent::for_stage(Stage::Code, PhaseId::new(7));
301
302        // Claude: stream-json argv + byte-identical legacy prompt.
303        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        // OpenCode: positional `run <prompt>` + byte-identical legacy prompt.
315        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    /// 37-03: Codex/Pi drivers. Codex carries the verified non-interactive
325    /// approval flag BEFORE `exec`; Pi keeps the Phase-36 `-p --no-approve`
326    /// argv and renders the de-Claude-ified workflow prompt.
327    #[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    /// 37-04: every driver passes the shared conformance suite, and Codex
362    /// declares the Define/Plan interactivity requirement that replaces the
363    /// hardcoded Codex-Define check.
364    #[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    /// A deliberately-broken driver: empty render + empty program. The suite
391    /// must FAIL it — the negative control proving `test_contract` isn't
392    /// vacuous (code-review finding #7).
393    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    /// The workflow renderer must preserve the per-stage contracts (code-review
422    /// findings #1-5): Validate verdict, Ship review gate, Define no-op, Plan
423    /// idempotency, and a per-driver workflow root.
424    #[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        // Validate demands the verdict (finding #1).
432        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        // Ship keeps the review gate (finding #2).
438        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        // Define is the D-14 no-op (finding #3).
443        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        // Plan keeps the idempotency guard (finding #3).
448        let plan = codex.render_prompt(&StageIntent::for_stage(Stage::Plan, PhaseId::new(7)));
449        assert!(plan.contains("already exists"));
450
451        // Pi points at its own workflow root (finding #5).
452        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    /// The shared-prompt invariant is retired (999.31 / 37-01): Claude and
478    /// OpenCode still render byte-identical legacy text, but Codex now renders
479    /// a Codex-native instruction instead of the shared `/gsd-*` slash command.
480    #[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        // Claude/OpenCode: byte-identical legacy text (zero regression).
488        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        // Codex: native, NOT the shared slash-command text (the dogfood fix).
499        assert_ne!(
500            codex, claude,
501            "Codex must no longer render the shared slash-command text"
502        );
503        // Negative control, precise: no GSD slash COMMAND may appear (the
504        // `gsd-core` workflow-directory path is legitimate and must not trip
505        // a naive `/gsd-` substring check).
506        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        // Positive oracle: the native instruction references the workflow path,
521        // carries the --auto token, and states the completion contract (so an
522        // empty or \"do nothing\" string cannot pass).
523        assert!(codex.contains("execute-phase.md"));
524        assert!(codex.contains("--auto"));
525        assert!(codex.contains("DEVFLOW_RESULT"));
526    }
527
528    /// The Phase 31 launch contract, asserted as one thing because getting
529    /// only the flags right is the documented way to half-implement it: the
530    /// transport is `stream-json` in BOTH directions, and the prompt is not a
531    /// positional argument at all.
532    #[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    /// 13-06 dogfood regression (Codex leg): linked-worktree git metadata
582    /// lives under the main repo's `.git/` — outside the workspace-write
583    /// sandbox — and Codex read-only-mounts the cwd's resolved git dir, so
584    /// BOTH the common `.git` and the worktree admin dir must be granted
585    /// (verified with `codex sandbox` probes). Without roots, no override.
586    #[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    /// 13-06 dogfood regression: signed commits fail inside the Codex
611    /// sandbox (no route to the operator's signing agent) — codex scopes an
612    /// unsigned-commit override to its own process tree via GIT_CONFIG_*
613    /// env; agents without a sandbox get no extra env.
614    #[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    /// D-13: `preflight`'s default body is `Ok(())` for every built-in
624    /// adapter — none of Claude/Codex/OpenCode override it in Phase 17 (no
625    /// reviewer-set storage exists yet in `state.rs`/`config.rs`, review
626    /// consensus #6).
627    #[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}