Skip to main content

devflow_core/agents/
claude.rs

1//! Claude Code agent driver.
2//!
3//! Launches `claude -p` headless with a bidirectional `stream-json` transport:
4//! the initial user turn travels on the child's **stdin**, and its events come
5//! back on stdout one JSON object per line. Claude runs headless — no trust
6//! dialogs, no user prompts.
7
8use super::AgentDriver;
9use crate::phase_id::PhaseId;
10
11/// The modular driver for Claude (37-02): owns the `stream-json` launch,
12/// legacy prompt rendering, the pre-31 single-document builder, and the
13/// checkpoint-resume relaunch command.
14pub struct ClaudeDriver;
15
16impl AgentDriver for ClaudeDriver {
17    fn name(&self) -> &'static str {
18        "Claude Code"
19    }
20
21    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
22        crate::prompt::render_claude_style(intent)
23    }
24
25    /// Build the headless `stream-json` launch (Phase 31, constraint 1).
26    ///
27    /// **The prompt is deliberately absent from the returned argv.** Under
28    /// `--input-format stream-json` the CLI takes its initial user turn from
29    /// stdin as a JSON document, not from a positional argument; the monitor
30    /// writes that turn via `crate::monitor::user_turn_line`. The `prompt`
31    /// parameter is kept in the signature for the shared `AgentDriver` shape —
32    /// it is unused here on purpose, not by oversight.
33    ///
34    /// `--verbose` is load-bearing, not decoration: every archived Phase-30
35    /// trial that produced a usable capture carried it, and dropping it is
36    /// untested territory. Do not "clean it up".
37    ///
38    /// The switch is unconditional and stage-blind — which stages route here is
39    /// a rollout-order choice made at the call site
40    /// (`claude_stream_launch_enabled`), not a prediction this builder makes.
41    fn build_command(
42        &self,
43        _phase: PhaseId,
44        _prompt: &str,
45        _extra_writable_roots: &[std::path::PathBuf],
46    ) -> (&'static str, Vec<String>) {
47        (
48            "claude",
49            vec![
50                "-p".into(),
51                "--input-format".into(),
52                "stream-json".into(),
53                "--output-format".into(),
54                "stream-json".into(),
55                "--verbose".into(),
56                "--dangerously-skip-permissions".into(),
57            ],
58        )
59    }
60}
61
62impl ClaudeDriver {
63    /// The pre-31 single-document launch: `-p <prompt>` positionally with
64    /// `--output-format json`.
65    ///
66    /// **This is a live path, not a deprecated leftover.** Two things select
67    /// it, and both are deliberate:
68    ///
69    /// - **D-09/D-10's sequencing gate.** The stream-json launch is rolled out
70    ///   one stage at a time, starting at `Stage::Code`. Every stage not yet
71    ///   widened launches through here. That is a sequencing choice about
72    ///   rollout order, which constraint 1 permits — it is emphatically not a
73    ///   prediction about which stages background work, which constraint 1
74    ///   forbids.
75    /// - **D-11's opt-out.** An explicit flag (off by default) can force this
76    ///   shape back on for recovery without cutting a release. Automatic
77    ///   fallback on parse failure is rejected: a silent downgrade is the same
78    ///   invisible-degradation class as the bug Phase 31 exists to fix.
79    ///
80    /// The argv is the pre-31 single-document launch body verbatim, so
81    /// the shipped capture shape (`CaptureKind::SingleDocEnvelope`) and the
82    /// 30b isolation tests that guard it (D-12) keep holding bit-for-bit.
83    pub fn exec_command_single_document(prompt: &str) -> (&'static str, Vec<String>) {
84        (
85            "claude",
86            vec![
87                "-p".into(),
88                prompt.to_string(),
89                "--output-format".into(),
90                "json".into(),
91                "--dangerously-skip-permissions".into(),
92            ],
93        )
94    }
95
96    /// Build the resume relaunch command for a confirmed checkpoint
97    /// auto-decide (D-03/D-04, 28-03). Not a trait method — `--resume` is a
98    /// Claude-CLI-specific, documented feature (D-05: Claude-only, no Codex/OpenCode
99    /// accommodation).
100    ///
101    /// Argv order (RESEARCH.md § "Architecture Patterns / Pattern 4",
102    /// confirmed): the print flag, the instruction, the resume flag
103    /// immediately followed by the session id (so the id is parsed as the
104    /// flag's value, not a positional argument), the output-format flag with
105    /// its JSON value, and the permission-bypass flag.
106    ///
107    /// **Pitfall 1 (RESEARCH.md, T-28-02) — load-bearing, do not "clean up":**
108    /// a `claude --resume`d session restores NEITHER the permission mode NOR
109    /// the output format from the original launch. Both are re-passed here
110    /// explicitly even though they look redundant with `exec_command`'s
111    /// launch above. Omitting either reintroduces the exact headless hang
112    /// this phase exists to close: the resumed session halts on a
113    /// permission prompt with no operator present to answer it, and the
114    /// prompt is not guaranteed to even reach the captured stdout.
115    /// `resume_command_includes_permission_bypass` is the named regression
116    /// test guarding this specifically — do not delete it as "obviously
117    /// redundant" with the launch-contract tests above; it guards a DIFFERENT
118    /// command construction path. Note the resume argv keeps `--output-format
119    /// json` and a POSITIONAL instruction even though `exec_command` no longer
120    /// does: a resumed session is a single-document relaunch, not a
121    /// stream-json one.
122    pub fn exec_resume_command(session_id: &str, instruction: &str) -> (&'static str, Vec<String>) {
123        (
124            "claude",
125            vec![
126                "-p".into(),
127                instruction.to_string(),
128                "--resume".into(),
129                session_id.to_string(),
130                "--output-format".into(),
131                "json".into(),
132                "--dangerously-skip-permissions".into(),
133            ],
134        )
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    /// A stand-in for the stage prompt's one invariant substring. Every real
143    /// stage prompt carries the `DEVFLOW_RESULT` contract, so its presence in
144    /// an argument is what identifies that argument as the prompt.
145    const PROMPT: &str = "do the work, then emit DEVFLOW_RESULT: {...}";
146
147    /// Both directions, or neither works. Flipping only `--output-format`
148    /// leaves the CLI with no first turn (the prompt has left argv but nothing
149    /// is writing it to stdin) and it stalls headless — the failure RESEARCH
150    /// Pitfall 1 names, whose warning sign is an `init` event followed by the
151    /// agent asking what to do.
152    #[test]
153    fn exec_command_uses_stream_json_on_both_input_and_output() {
154        let (program, args) = ClaudeDriver.build_command(PhaseId::new(7), PROMPT, &[]);
155        assert_eq!(program, "claude");
156        assert!(
157            args.windows(2)
158                .any(|w| w[0] == "--input-format" && w[1] == "stream-json"),
159            "the input format is what moves the initial turn onto stdin: {args:?}"
160        );
161        assert!(
162            args.windows(2)
163                .any(|w| w[0] == "--output-format" && w[1] == "stream-json"),
164            "the output format is what makes the capture a JSONL event stream: {args:?}"
165        );
166        assert!(
167            args.iter().any(|a| a == "--verbose"),
168            "every archived Phase 30 trial that produced a usable capture \
169             carried --verbose; dropping it is untested territory: {args:?}"
170        );
171        assert!(
172            args.iter().any(|a| a == "--dangerously-skip-permissions"),
173            "a headless launch with no operator present cannot answer a \
174             permission prompt: {args:?}"
175        );
176    }
177
178    /// The half of the change that is easy to miss. RESEARCH Pitfall 1: the
179    /// ROADMAP and CONTEXT both describe Phase 31 as "the argv flip", which
180    /// reads as flags-only — but leaving the prompt at `args[1]` under
181    /// `--input-format stream-json` is not documented to work, and was never
182    /// tested in Phase 30.
183    #[test]
184    fn exec_command_carries_no_positional_prompt() {
185        let (_program, args) = ClaudeDriver.build_command(PhaseId::new(7), PROMPT, &[]);
186        assert!(
187            !args.iter().any(|arg| arg.contains("DEVFLOW_RESULT")),
188            "the prompt must not appear in argv at all — it travels as a JSON \
189             user turn on the child's stdin, written by the monitor: {args:?}"
190        );
191    }
192
193    /// The pre-31 shape must stay REACHABLE, not merely present. Two live
194    /// selectors depend on it: D-11's opt-out (recovery without a release) and
195    /// the D-09/D-10 sequencing gate (every stage the rollout has not reached
196    /// yet). If this builder silently drifted toward the stream-json shape,
197    /// both would land on a launch that is not pre-31 at all, and the D-12
198    /// isolation guarantee for the shipped single-document capture would go
199    /// with it.
200    #[test]
201    fn single_document_command_preserves_pre31_shape() {
202        let (program, args) = ClaudeDriver::exec_command_single_document(PROMPT);
203        assert_eq!(program, "claude");
204        assert!(
205            args.windows(2).any(|w| w[0] == "-p" && w[1] == PROMPT),
206            "the prompt must follow -p POSITIONALLY, as it did pre-31: {args:?}"
207        );
208        assert!(
209            args.windows(2)
210                .any(|w| w[0] == "--output-format" && w[1] == "json"),
211            "the single-document envelope is what makes this capture classify \
212             as SingleDocEnvelope and keep the raw-scan path: {args:?}"
213        );
214        assert!(
215            args.iter().any(|a| a == "--dangerously-skip-permissions"),
216            "the opt-out path is still headless: {args:?}"
217        );
218        assert!(
219            !args.iter().any(|a| a == "--input-format"),
220            "this builder must NOT drift toward the stream-json shape — that \
221             would leave D-11's opt-out with nothing to opt out to: {args:?}"
222        );
223    }
224
225    #[test]
226    fn resume_command_names_claude_program() {
227        let (program, _args) = ClaudeDriver::exec_resume_command("sess", "instr");
228        assert_eq!(program, "claude");
229    }
230
231    #[test]
232    fn resume_command_carries_print_flag_and_instruction() {
233        let (_program, args) = ClaudeDriver::exec_resume_command("sess", "do the thing");
234        assert!(args.iter().any(|a| a == "-p"));
235        assert!(args.iter().any(|a| a == "do the thing"));
236    }
237
238    #[test]
239    fn resume_command_resume_flag_immediately_precedes_session_id() {
240        let (_program, args) = ClaudeDriver::exec_resume_command("sess-abc", "instr");
241        let resume_idx = args
242            .iter()
243            .position(|a| a == "--resume")
244            .expect("--resume flag must be present");
245        assert_eq!(
246            args.get(resume_idx + 1).map(String::as_str),
247            Some("sess-abc"),
248            "the session id must immediately follow --resume so it is parsed \
249             as the flag's value, not a positional argument: {args:?}"
250        );
251    }
252
253    /// Pitfall 1 (RESEARCH.md, T-28-02): the single highest-consequence
254    /// regression this phase can ship is a resume relaunch that omits either
255    /// the permission-bypass flag or the JSON output-format flag — a resumed
256    /// Claude session restores neither, so omitting them reintroduces a
257    /// silent headless hang on a permission prompt nobody can answer.
258    #[test]
259    fn resume_command_includes_permission_bypass() {
260        let (program, args) = ClaudeDriver::exec_resume_command("sess-123", "do the thing");
261        assert_eq!(program, "claude");
262        assert!(
263            args.iter().any(|a| a == "--dangerously-skip-permissions"),
264            "a resumed Claude session restores neither the permission mode \
265             nor the output format (RESEARCH Pitfall 1) — omitting this flag \
266             reintroduces a silent headless hang with nobody able to answer \
267             the resulting permission prompt: {args:?}"
268        );
269        assert!(
270            args.windows(2)
271                .any(|w| w[0] == "--output-format" && w[1] == "json"),
272            "the JSON output-format flag must also be re-passed explicitly, \
273             for the same reason as the permission-bypass flag: {args:?}"
274        );
275    }
276}