Skip to main content

devflow_core/agents/
claude.rs

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