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