Skip to main content

apm_core/
start.rs

1use anyhow::{bail, Context, Result};
2use crate::{config::{Config, WorkerProfileConfig, WorkersConfig}, git, ticket, ticket_fmt};
3use crate::wrapper::{WrapperContext, write_temp_file};
4use chrono::Utc;
5use std::path::{Path, PathBuf};
6
7const CLAUDE_WORKER_DEFAULT: &str = include_str!("default/agents/claude/apm.worker.md");
8const CLAUDE_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/claude/apm.spec-writer.md");
9const MOCK_HAPPY_WORKER_DEFAULT: &str = include_str!("default/agents/mock-happy/apm.worker.md");
10const MOCK_HAPPY_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-happy/apm.spec-writer.md");
11const MOCK_SAD_WORKER_DEFAULT: &str = include_str!("default/agents/mock-sad/apm.worker.md");
12const MOCK_SAD_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-sad/apm.spec-writer.md");
13const MOCK_RANDOM_WORKER_DEFAULT: &str = include_str!("default/agents/mock-random/apm.worker.md");
14const MOCK_RANDOM_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-random/apm.spec-writer.md");
15const DEBUG_WORKER_DEFAULT: &str = include_str!("default/agents/debug/apm.worker.md");
16const DEBUG_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/debug/apm.spec-writer.md");
17
18static DEPRECATION_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
19
20#[cfg(test)]
21static DEPRECATION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
22
23const DEPRECATION_MSG: &str = "apm: deprecated: `[workers] command`, `args`, and `model` fields are deprecated — migrate to `agent` and `[workers.options]`";
24
25/// Delay inserted between `git fetch` and `git merge` in aggressive mode to let
26/// remote-propagation settle and reduce the fetch-race window.
27const POST_FETCH_SETTLE_MS: u64 = 1_000;
28
29fn emit_deprecation_warning_to(out: &mut dyn std::io::Write) {
30    use std::sync::atomic::Ordering;
31    if DEPRECATION_WARNED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok() {
32        let _ = writeln!(out, "{DEPRECATION_MSG}");
33    }
34}
35
36fn emit_deprecation_warning() {
37    emit_deprecation_warning_to(&mut std::io::stderr().lock());
38}
39
40pub struct EffectiveWorkerParams {
41    pub command: String,
42    pub args: Vec<String>,
43    pub model: Option<String>,
44    pub env: std::collections::HashMap<String, String>,
45    pub container: Option<String>,
46    pub agent: String,
47    pub options: std::collections::HashMap<String, String>,
48}
49
50fn resolve_profile<'a>(transition: &crate::config::TransitionConfig, config: &'a Config, warnings: &mut Vec<String>) -> Option<&'a WorkerProfileConfig> {
51    let name = transition.profile.as_deref()?;
52    match config.worker_profiles.get(name) {
53        Some(p) => Some(p),
54        None => {
55            warnings.push(format!("warning: worker profile {name:?} not found — using global [workers] config"));
56            None
57        }
58    }
59}
60
61pub fn effective_spawn_params(transition_agent: Option<&str>, profile: Option<&WorkerProfileConfig>, workers: &WorkersConfig) -> EffectiveWorkerParams {
62    // Legacy command/args (kept for check_output_format_supported backward compat)
63    let command = profile.and_then(|p| p.command.clone())
64        .or_else(|| workers.command.clone())
65        .unwrap_or_else(|| "claude".to_string());
66    let args = profile.and_then(|p| p.args.clone())
67        .or_else(|| workers.args.clone())
68        .unwrap_or_else(|| vec!["--print".to_string()]);
69
70    // Agent resolution: transition > profile > workers > default "claude"
71    let raw_agent = transition_agent
72        .map(str::to_owned)
73        .or_else(|| profile.and_then(|p| p.agent.clone()))
74        .or_else(|| workers.agent.clone());
75
76    // Emit deprecation warning when legacy fields present but agent absent
77    let has_legacy = workers.command.is_some()
78        || workers.args.is_some()
79        || workers.model.is_some()
80        || profile.map(|p| p.command.is_some() || p.args.is_some() || p.model.is_some()).unwrap_or(false);
81    if raw_agent.is_none() && has_legacy {
82        emit_deprecation_warning();
83    }
84
85    let agent = raw_agent.unwrap_or_else(|| "claude".to_string());
86
87    // Options merge: workers.options base, profile.options overrides on collision
88    let mut options = workers.options.clone();
89    if let Some(p) = profile {
90        for (k, v) in &p.options {
91            options.insert(k.clone(), v.clone());
92        }
93    }
94
95    // Model: options.model > legacy profile.model > legacy workers.model
96    let model = options.get("model").cloned()
97        .or_else(|| profile.and_then(|p| p.model.clone()))
98        .or_else(|| workers.model.clone());
99
100    // Env merge
101    let mut env = workers.env.clone();
102    if let Some(p) = profile {
103        for (k, v) in &p.env {
104            env.insert(k.clone(), v.clone());
105        }
106    }
107
108    let container = profile.and_then(|p| p.container.clone())
109        .or_else(|| workers.container.clone());
110
111    EffectiveWorkerParams { command, args, model, env, container, agent, options }
112}
113
114fn apply_frontmatter_agent(
115    agent: &mut String,
116    frontmatter: &ticket_fmt::Frontmatter,
117    profile_name: &str,
118) {
119    if let Some(ov) = frontmatter.agent_overrides.get(profile_name) {
120        *agent = ov.clone();
121    } else if let Some(a) = &frontmatter.agent {
122        *agent = a.clone();
123    }
124    // else: keep config-resolved agent unchanged
125}
126
127pub struct StartOutput {
128    pub id: String,
129    pub old_state: String,
130    pub new_state: String,
131    pub agent_name: String,
132    pub branch: String,
133    pub worktree_path: PathBuf,
134    pub merge_message: Option<String>,
135    pub worker_pid: Option<u32>,
136    pub log_path: Option<PathBuf>,
137    pub worker_name: Option<String>,
138    pub warnings: Vec<String>,
139}
140
141pub struct RunNextOutput {
142    pub ticket_id: Option<String>,
143    pub messages: Vec<String>,
144    pub warnings: Vec<String>,
145    pub worker_pid: Option<u32>,
146    pub log_path: Option<PathBuf>,
147}
148
149/// True when `agent` resolves to the built-in claude wrapper (no custom shadow).
150/// The compatibility probe is only meaningful in that case.
151pub(crate) fn should_check_claude_compat(root: &Path, agent: &str) -> bool {
152    if agent != "claude" { return false; }
153    matches!(
154        crate::wrapper::resolve_wrapper(root, "claude"),
155        Ok(Some(crate::wrapper::WrapperKind::Builtin(_)))
156    )
157}
158
159pub(crate) fn check_output_format_supported(binary: &str) -> Result<()> {
160    let out = std::process::Command::new(binary)
161        .arg("--help")
162        .output()
163        .map_err(|e| anyhow::anyhow!(
164            "failed to run `{binary} --help` to check worker-driver compatibility: {e}"
165        ))?;
166    let combined = format!(
167        "{}{}",
168        String::from_utf8_lossy(&out.stdout),
169        String::from_utf8_lossy(&out.stderr)
170    );
171    if combined.contains("--output-format") {
172        Ok(())
173    } else {
174        bail!(
175            "worker binary `{binary}` does not advertise `--output-format` in its \
176             --help output; the flag `--output-format stream-json` is required for \
177             full transcript capture in .apm-worker.log.\n\
178             Upgrade the binary to a version that supports this flag, or configure \
179             an alternative worker command in your .apm/config.toml [workers] section."
180        )
181    }
182}
183
184pub struct ManagedChild {
185    pub inner: std::process::Child,
186    temp_files: Vec<PathBuf>,
187    /// When set, denial scanning is run on drop (claude wrapper only).
188    /// Tuple: (log_path, worktree_path, ticket_id).
189    denial_ctx: Option<(PathBuf, PathBuf, String)>,
190}
191
192impl std::ops::Deref for ManagedChild {
193    type Target = std::process::Child;
194    fn deref(&self) -> &std::process::Child { &self.inner }
195}
196
197impl std::ops::DerefMut for ManagedChild {
198    fn deref_mut(&mut self) -> &mut std::process::Child { &mut self.inner }
199}
200
201impl Drop for ManagedChild {
202    fn drop(&mut self) {
203        for f in &self.temp_files {
204            let _ = std::fs::remove_file(f);
205        }
206        if let Some((log_path, worktree_path, ticket_id)) = &self.denial_ctx {
207            run_denial_scan(log_path, worktree_path, ticket_id);
208        }
209    }
210}
211
212fn spawn_worker(ctx: &WrapperContext, agent: &str, project_root: &Path) -> Result<std::process::Child> {
213    use crate::wrapper::{resolve_wrapper, resolve_builtin, WrapperKind, Wrapper};
214    use crate::wrapper::custom::CustomWrapper;
215
216    match resolve_wrapper(project_root, agent)? {
217        Some(WrapperKind::Custom { script_path, manifest }) => {
218            CustomWrapper { script_path, manifest }.spawn(ctx)
219        }
220        Some(WrapperKind::Builtin(name)) => {
221            resolve_builtin(&name).expect("known built-in").spawn(ctx)
222        }
223        None => anyhow::bail!(
224            "agent {:?} not found: checked built-ins {{{}}} and '.apm/agents/{agent}/'",
225            agent,
226            crate::wrapper::list_builtin_names().join(", ")
227        ),
228    }
229}
230
231/// Scan the worker transcript for permission denials, write the summary file,
232/// and emit a warning to the APM log when apm-command denials are found.
233fn run_denial_scan(log_path: &Path, worktree: &Path, ticket_id: &str) {
234    let summary = crate::denial::scan_transcript(log_path, worktree, ticket_id);
235    let summary_path = crate::denial::summary_path_for(log_path);
236    crate::denial::write_summary(&summary_path, &summary);
237    let unique_cmds = crate::denial::collect_unique_apm_commands(&summary);
238    if !unique_cmds.is_empty() {
239        crate::logger::log(
240            "worker-diag",
241            &format!(
242                "apm_command_denial ticket {} denied apm commands: {}",
243                ticket_id,
244                unique_cmds.join(", ")
245            ),
246        );
247    }
248}
249
250pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_permissions: bool, agent_name: &str) -> Result<StartOutput> {
251    let mut warnings: Vec<String> = Vec::new();
252    let config = Config::load(root)?;
253    let aggressive = config.sync.aggressive && !no_aggressive;
254    let skip_permissions = skip_permissions || config.agents.skip_permissions;
255
256    let startable: Vec<&str> = config.workflow.states.iter()
257        .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
258        .map(|s| s.id.as_str())
259        .collect();
260
261    let mut tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
262    let id = ticket::resolve_id_in_slice(&tickets, id_arg)?;
263
264    let Some(t) = tickets.iter_mut().find(|t| t.frontmatter.id == id) else {
265        bail!("ticket {id:?} not found");
266    };
267
268    let ticket_epic_id = t.frontmatter.epic.clone();
269    let ticket_depends_on = t.frontmatter.depends_on.clone().unwrap_or_default();
270    let fm = &t.frontmatter;
271    if !startable.is_empty() && !startable.contains(&fm.state.as_str()) {
272        bail!(
273            "ticket {id:?} is in state {:?} — not startable\n\
274             Use `apm start` only from: {}",
275            fm.state,
276            startable.join(", ")
277        );
278    }
279
280    let now = Utc::now();
281    let old_state = t.frontmatter.state.clone();
282
283    let triggering_transition = config.workflow.states.iter()
284        .find(|s| s.id == old_state)
285        .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
286
287    let new_state = triggering_transition
288        .map(|tr| tr.to.clone())
289        .unwrap_or_else(|| "in_progress".into());
290
291    t.frontmatter.state = new_state.clone();
292    t.frontmatter.updated_at = Some(now);
293    let when = now.format("%Y-%m-%dT%H:%MZ").to_string();
294    crate::state::append_history(&mut t.body, &old_state, &new_state, &when, agent_name);
295
296    let content = t.serialize()?;
297    let rel_path = format!(
298        "{}/{}",
299        config.tickets.dir.to_string_lossy(),
300        t.path.file_name().unwrap().to_string_lossy()
301    );
302    let branch = t
303        .frontmatter
304        .branch
305        .clone()
306        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
307        .unwrap_or_else(|| format!("ticket/{id}"));
308
309    let default_branch = &config.project.default_branch;
310    let merge_base = t.frontmatter.target_branch.clone()
311        .unwrap_or_else(|| default_branch.to_string());
312
313    if aggressive {
314        if let Err(e) = git::fetch_branch(root, &branch) {
315            warnings.push(format!("warning: fetch failed: {e:#}"));
316        }
317        if let Err(e) = git::fetch_branch(root, default_branch) {
318            warnings.push(format!("warning: fetch {} failed: {e:#}", default_branch));
319        }
320        std::thread::sleep(std::time::Duration::from_millis(POST_FETCH_SETTLE_MS));
321    }
322
323    git::commit_to_branch(root, &branch, &rel_path, &content, &format!("ticket({id}): start — {old_state} → {new_state}"))?;
324
325    let wt_display = crate::worktree::provision_worktree(root, &config, &branch, &mut warnings)?;
326
327    let ref_to_merge = if crate::git_util::remote_branch_tip(&wt_display, &merge_base).is_some() {
328        format!("origin/{merge_base}")
329    } else {
330        merge_base.to_string()
331    };
332    let merge_message = crate::git_util::merge_ref(&wt_display, &ref_to_merge, &mut warnings);
333
334    if !spawn {
335        return Ok(StartOutput {
336            id,
337            old_state,
338            new_state,
339            agent_name: agent_name.to_string(),
340            branch,
341            worktree_path: wt_display,
342            merge_message,
343            worker_pid: None,
344            log_path: None,
345            worker_name: None,
346            warnings,
347        });
348    }
349
350    let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
351    let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
352
353    let profile_name = triggering_transition
354        .and_then(|tr| tr.profile.as_deref())
355        .unwrap_or("")
356        .to_string();
357    let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings));
358    let role = profile.and_then(|p| p.role.as_deref()).unwrap_or("worker");
359    let mut params = effective_spawn_params(triggering_transition.and_then(|tr| tr.agent.as_deref()), profile, &config.workers);
360    apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name);
361    let tr_instructions = triggering_transition.and_then(|tr| tr.instructions.as_deref());
362    let tr_role_prefix = triggering_transition.and_then(|tr| tr.role_prefix.as_deref());
363    let worker_system = resolve_system_prompt(root, tr_instructions, profile, &config.workers, &params.agent, role)?;
364    let raw_prompt = format!("{}\n\n{content}", agent_role_prefix(tr_role_prefix, profile, &id));
365    let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt);
366    let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic);
367    let role_prefix = tr_role_prefix
368        .map(|p| p.replace("<id>", &id))
369        .or_else(|| profile.and_then(|p| p.role_prefix.clone()));
370
371    let log_path = wt_display.join(".apm-worker.log");
372
373    let sys_file = write_temp_file("sys", &worker_system)?;
374    let msg_file = write_temp_file("msg", &ticket_content)?;
375    let ctx = WrapperContext {
376        worker_name: worker_name.clone(),
377        ticket_id: id.clone(),
378        ticket_branch: branch.clone(),
379        worktree_path: wt_display.clone(),
380        system_prompt_file: sys_file.clone(),
381        user_message_file: msg_file.clone(),
382        skip_permissions,
383        profile: profile_name,
384        role_prefix,
385        options: params.options.clone(),
386        model: params.model.clone(),
387        log_path: log_path.clone(),
388        container: params.container.clone(),
389        extra_env: params.env.clone(),
390        root: root.to_path_buf(),
391        keychain: config.workers.keychain.clone(),
392        current_state: new_state.clone(),
393        command: Some(params.command.clone()),
394    };
395    if should_check_claude_compat(root, &params.agent) {
396        check_output_format_supported(&params.command)?;
397    }
398    let mut child = spawn_worker(&ctx, &params.agent, root)?;
399    let pid = child.id();
400
401    let pid_path = wt_display.join(".apm-worker.pid");
402    write_pid_file(&pid_path, pid, &id)?;
403
404    let enforce_isolation = skip_permissions || config.isolation.enforce_worktree_isolation;
405    let wt_for_cleanup = wt_display.clone();
406    let denial_log_path = log_path.clone();
407    let denial_worktree = wt_display.clone();
408    let denial_ticket_id = id.clone();
409    let agent_for_diag = params.agent.clone();
410    std::thread::spawn(move || {
411        let _ = child.wait();
412        let _ = std::fs::remove_file(&sys_file);
413        let _ = std::fs::remove_file(&msg_file);
414        if agent_for_diag == "claude" {
415            run_denial_scan(&denial_log_path, &denial_worktree, &denial_ticket_id);
416        }
417        if enforce_isolation {
418            let _ = crate::wrapper::hook_config::remove_hook_config(&wt_for_cleanup);
419        }
420    });
421
422    Ok(StartOutput {
423        id,
424        old_state,
425        new_state,
426        agent_name: agent_name.to_string(),
427        branch,
428        worktree_path: wt_display,
429        merge_message,
430        worker_pid: Some(pid),
431        log_path: Some(log_path),
432        worker_name: Some(worker_name),
433        warnings,
434    })
435}
436
437pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: bool) -> Result<RunNextOutput> {
438    let mut messages: Vec<String> = Vec::new();
439    let mut warnings: Vec<String> = Vec::new();
440    let config = Config::load(root)?;
441    let skip_permissions = skip_permissions || config.agents.skip_permissions;
442    let p = &config.workflow.prioritization;
443    let startable: Vec<&str> = config.workflow.states.iter()
444        .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
445        .map(|s| s.id.as_str())
446        .collect();
447    let actionable_owned = config.actionable_states_for("agent");
448    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
449    let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
450    let agent_name = crate::config::resolve_caller_name();
451    let current_user = crate::config::resolve_identity(root);
452
453    // Filter out tickets whose epic already has the max number of active workers.
454    let active_epic_ids: Vec<Option<String>> = all_tickets.iter()
455        .filter(|t| {
456            let s = t.frontmatter.state.as_str();
457            actionable.contains(&s) && !startable.contains(&s)
458        })
459        .map(|t| t.frontmatter.epic.clone())
460        .collect();
461    let blocked = config.blocked_epics(&active_epic_ids);
462    let default_blocked = config.is_default_branch_blocked(&active_epic_ids);
463    let tickets: Vec<_> = all_tickets.into_iter()
464        .filter(|t| match t.frontmatter.epic.as_deref() {
465            Some(eid) => !blocked.iter().any(|b| b == eid),
466            None => !default_blocked,
467        })
468        .collect();
469
470    let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&agent_name), Some(&current_user)) else {
471        messages.push("No actionable tickets.".to_string());
472        return Ok(RunNextOutput { ticket_id: None, messages, warnings, worker_pid: None, log_path: None });
473    };
474
475    let id = candidate.frontmatter.id.clone();
476    let old_state = candidate.frontmatter.state.clone();
477
478    let triggering_transition_owned = config.workflow.states.iter()
479        .find(|s| s.id == old_state)
480        .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
481        .cloned();
482    let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
483    let state_instructions = config.workflow.states.iter()
484        .find(|s| s.id == old_state)
485        .and_then(|sc| sc.instructions.as_deref())
486        .map(|s| s.to_string());
487    let instructions_text = profile
488        .and_then(|p| p.instructions.as_deref())
489        .map(|path| {
490            match std::fs::read_to_string(root.join(path)) {
491                Ok(s) => s,
492                Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
493            }
494        })
495        .filter(|s| !s.is_empty())
496        .or_else(|| state_instructions.as_deref()
497            .and_then(|path| {
498                std::fs::read_to_string(root.join(path)).ok()
499                    .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
500            }));
501    let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
502    warnings.extend(start_out.warnings);
503
504    if let Some(ref msg) = start_out.merge_message {
505        messages.push(msg.clone());
506    }
507    messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
508    messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
509
510    let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
511    let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
512        return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
513    };
514
515    let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
516        let hint = format!("Pay special attention to section: {section}");
517        let rel_path = format!(
518            "{}/{}",
519            config.tickets.dir.to_string_lossy(),
520            t.path.file_name().unwrap().to_string_lossy()
521        );
522        let branch = t.frontmatter.branch.clone()
523            .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
524            .unwrap_or_else(|| format!("ticket/{id}"));
525        let mut t_mut = t.clone();
526        t_mut.frontmatter.focus_section = None;
527        let cleared = t_mut.serialize()?;
528        git::commit_to_branch(root, &branch, &rel_path, &cleared, &format!("ticket({id}): clear focus_section"))?;
529        Some(hint)
530    } else {
531        None
532    };
533
534    let mut prompt = String::new();
535    if let Some(ref instr) = instructions_text {
536        prompt.push_str(instr.trim());
537        prompt.push('\n');
538    }
539    if let Some(ref hint) = focus_hint {
540        if !prompt.is_empty() { prompt.push('\n'); }
541        prompt.push_str(hint);
542        prompt.push('\n');
543    }
544
545    if !spawn {
546        if !prompt.is_empty() {
547            messages.push(format!("Prompt:\n{prompt}"));
548        }
549        return Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: None, log_path: None });
550    }
551
552    let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
553    let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
554
555    let profile_name2 = triggering_transition_owned.as_ref()
556        .and_then(|tr| tr.profile.as_deref())
557        .unwrap_or("")
558        .to_string();
559    let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings));
560    let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker");
561    let mut params = effective_spawn_params(triggering_transition_owned.as_ref().and_then(|tr| tr.agent.as_deref()), profile2, &config.workers);
562    apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2);
563    let tr_instructions2 = triggering_transition_owned.as_ref().and_then(|tr| tr.instructions.as_deref());
564    let tr_role_prefix2 = triggering_transition_owned.as_ref().and_then(|tr| tr.role_prefix.as_deref());
565    let worker_system = resolve_system_prompt(root, tr_instructions2, profile2, &config.workers, &params.agent, role2)?;
566
567    let raw = t.serialize()?;
568    let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default();
569    let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(tr_role_prefix2, profile2, &id));
570    let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next);
571    let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next);
572    let role_prefix2 = tr_role_prefix2
573        .map(|p| p.replace("<id>", &id))
574        .or_else(|| profile2.and_then(|p| p.role_prefix.clone()));
575
576    let branch = t.frontmatter.branch.clone()
577        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
578        .unwrap_or_else(|| format!("ticket/{id}"));
579    let wt_name = branch.replace('/', "-");
580    let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
581    let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
582    let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
583
584    let log_path = wt_display.join(".apm-worker.log");
585
586    let sys_file = write_temp_file("sys", &worker_system)?;
587    let msg_file = write_temp_file("msg", &ticket_content)?;
588    let ctx = WrapperContext {
589        worker_name: worker_name.clone(),
590        ticket_id: id.clone(),
591        ticket_branch: branch.clone(),
592        worktree_path: wt_display.clone(),
593        system_prompt_file: sys_file.clone(),
594        user_message_file: msg_file.clone(),
595        skip_permissions,
596        profile: profile_name2,
597        role_prefix: role_prefix2,
598        options: params.options.clone(),
599        model: params.model.clone(),
600        log_path: log_path.clone(),
601        container: params.container.clone(),
602        extra_env: params.env.clone(),
603        root: root.to_path_buf(),
604        keychain: config.workers.keychain.clone(),
605        current_state: t.frontmatter.state.clone(),
606        command: Some(params.command.clone()),
607    };
608    if should_check_claude_compat(root, &params.agent) {
609        check_output_format_supported(&params.command)?;
610    }
611    let mut child = spawn_worker(&ctx, &params.agent, root)?;
612    let pid = child.id();
613
614    let pid_path = wt_display.join(".apm-worker.pid");
615    write_pid_file(&pid_path, pid, &id)?;
616    let enforce_isolation_next = skip_permissions || config.isolation.enforce_worktree_isolation;
617    let wt_for_cleanup_next = wt_display.clone();
618    let denial_log_path2 = log_path.clone();
619    let denial_worktree2 = wt_display.clone();
620    let denial_ticket_id2 = id.clone();
621    let agent_for_diag2 = params.agent.clone();
622    std::thread::spawn(move || {
623        let _ = child.wait();
624        let _ = std::fs::remove_file(&sys_file);
625        let _ = std::fs::remove_file(&msg_file);
626        if agent_for_diag2 == "claude" {
627            run_denial_scan(&denial_log_path2, &denial_worktree2, &denial_ticket_id2);
628        }
629        if enforce_isolation_next {
630            let _ = crate::wrapper::hook_config::remove_hook_config(&wt_for_cleanup_next);
631        }
632    });
633
634    messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
635    messages.push(format!("Agent name: {worker_name}"));
636
637    Ok(RunNextOutput { ticket_id: Some(id), messages, warnings, worker_pid: Some(pid), log_path: Some(log_path) })
638}
639
640#[allow(clippy::type_complexity)]
641pub fn spawn_next_worker(
642    root: &Path,
643    no_aggressive: bool,
644    skip_permissions: bool,
645    epic_filter: Option<&str>,
646    blocked_epics: &[String],
647    default_blocked: bool,
648    messages: &mut Vec<String>,
649    warnings: &mut Vec<String>,
650) -> Result<Option<(String, Option<String>, ManagedChild, PathBuf)>> {
651    let config = Config::load(root)?;
652    let skip_permissions = skip_permissions || config.agents.skip_permissions;
653    let p = &config.workflow.prioritization;
654    let startable: Vec<&str> = config.workflow.states.iter()
655        .filter(|s| s.transitions.iter().any(|tr| tr.trigger == "command:start"))
656        .map(|s| s.id.as_str())
657        .collect();
658    let actionable_owned = config.actionable_states_for("agent");
659    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
660    let all_tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
661    let tickets: Vec<ticket::Ticket> = {
662        let epic_filtered: Vec<ticket::Ticket> = match epic_filter {
663            Some(epic_id) => all_tickets.into_iter()
664                .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
665                .collect(),
666            None => all_tickets,
667        };
668        epic_filtered.into_iter()
669            .filter(|t| match t.frontmatter.epic.as_deref() {
670                Some(eid) => !blocked_epics.iter().any(|b| b == eid),
671                None => !default_blocked,
672            })
673            .collect()
674    };
675    let agent_name = crate::config::resolve_caller_name();
676    let current_user = crate::config::resolve_identity(root);
677
678    let Some(candidate) = ticket::pick_next(&tickets, &actionable, &startable, p.priority_weight, p.effort_weight, p.risk_weight, &config, Some(&agent_name), Some(&current_user)) else {
679        return Ok(None);
680    };
681
682    let id = candidate.frontmatter.id.clone();
683    let epic_id = candidate.frontmatter.epic.clone();
684    let old_state = candidate.frontmatter.state.clone();
685
686    let triggering_transition_owned = config.workflow.states.iter()
687        .find(|s| s.id == old_state)
688        .and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"))
689        .cloned();
690    let profile = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
691    let state_instructions = config.workflow.states.iter()
692        .find(|s| s.id == old_state)
693        .and_then(|sc| sc.instructions.as_deref())
694        .map(|s| s.to_string());
695    let instructions_text = profile
696        .and_then(|p| p.instructions.as_deref())
697        .map(|path| {
698            match std::fs::read_to_string(root.join(path)) {
699                Ok(s) => s,
700                Err(_) => { warnings.push("warning: instructions file not found".to_string()); String::new() }
701            }
702        })
703        .filter(|s| !s.is_empty())
704        .or_else(|| state_instructions.as_deref()
705            .and_then(|path| {
706                std::fs::read_to_string(root.join(path)).ok()
707                    .or_else(|| { warnings.push("warning: instructions file not found".to_string()); None })
708            }));
709    let start_out = run(root, &id, no_aggressive, false, false, &agent_name)?;
710    warnings.extend(start_out.warnings);
711
712    if let Some(ref msg) = start_out.merge_message {
713        messages.push(msg.clone());
714    }
715    messages.push(format!("{}: {} → {} (agent: {}, branch: {})", start_out.id, start_out.old_state, start_out.new_state, start_out.agent_name, start_out.branch));
716    messages.push(format!("Worktree: {}", start_out.worktree_path.display()));
717
718    let tickets2 = ticket::load_all_from_git(root, &config.tickets.dir)?;
719    let Some(t) = tickets2.iter().find(|t| t.frontmatter.id == id) else {
720        return Ok(None);
721    };
722
723    let focus_hint = if let Some(ref section) = t.frontmatter.focus_section {
724        let hint = format!("Pay special attention to section: {section}");
725        let rel_path = format!(
726            "{}/{}",
727            config.tickets.dir.to_string_lossy(),
728            t.path.file_name().unwrap().to_string_lossy()
729        );
730        let branch = t.frontmatter.branch.clone()
731            .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
732            .unwrap_or_else(|| format!("ticket/{id}"));
733        let mut t_mut = t.clone();
734        t_mut.frontmatter.focus_section = None;
735        let cleared = t_mut.serialize()?;
736        git::commit_to_branch(root, &branch, &rel_path, &cleared,
737            &format!("ticket({id}): clear focus_section"))?;
738        Some(hint)
739    } else {
740        None
741    };
742
743    let mut prompt = String::new();
744    if let Some(ref instr) = instructions_text {
745        prompt.push_str(instr.trim());
746        prompt.push('\n');
747    }
748    if let Some(ref hint) = focus_hint {
749        if !prompt.is_empty() { prompt.push('\n'); }
750        prompt.push_str(hint);
751        prompt.push('\n');
752    }
753    let _ = prompt; // prompt used only for run_next, not spawn_next_worker
754
755    let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string();
756    let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16());
757
758    let profile_name2 = triggering_transition_owned.as_ref()
759        .and_then(|tr| tr.profile.as_deref())
760        .unwrap_or("")
761        .to_string();
762    let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings));
763    let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker");
764    let mut params = effective_spawn_params(triggering_transition_owned.as_ref().and_then(|tr| tr.agent.as_deref()), profile2, &config.workers);
765    apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2);
766    let tr_instructions_snw = triggering_transition_owned.as_ref().and_then(|tr| tr.instructions.as_deref());
767    let tr_role_prefix_snw = triggering_transition_owned.as_ref().and_then(|tr| tr.role_prefix.as_deref());
768    let worker_system = resolve_system_prompt(root, tr_instructions_snw, profile2, &config.workers, &params.agent, role2)?;
769
770    let raw = t.serialize()?;
771    let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default();
772    let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(tr_role_prefix_snw, profile2, &id));
773    let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw);
774    let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw);
775    let role_prefix2 = tr_role_prefix_snw
776        .map(|p| p.replace("<id>", &id))
777        .or_else(|| profile2.and_then(|p| p.role_prefix.clone()));
778
779    let branch = t.frontmatter.branch.clone()
780        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
781        .unwrap_or_else(|| format!("ticket/{id}"));
782    let wt_name = branch.replace('/', "-");
783    let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
784    let wt_path = main_root.join(&config.worktrees.dir).join(&wt_name);
785    let wt_display = crate::worktree::find_worktree_for_branch(root, &branch).unwrap_or(wt_path);
786
787    let log_path = wt_display.join(".apm-worker.log");
788
789    let sys_file = write_temp_file("sys", &worker_system)?;
790    let msg_file = write_temp_file("msg", &ticket_content)?;
791    let ctx = WrapperContext {
792        worker_name: worker_name.clone(),
793        ticket_id: id.clone(),
794        ticket_branch: branch.clone(),
795        worktree_path: wt_display.clone(),
796        system_prompt_file: sys_file.clone(),
797        user_message_file: msg_file.clone(),
798        skip_permissions,
799        profile: profile_name2,
800        role_prefix: role_prefix2,
801        options: params.options.clone(),
802        model: params.model.clone(),
803        log_path: log_path.clone(),
804        container: params.container.clone(),
805        extra_env: params.env.clone(),
806        root: root.to_path_buf(),
807        keychain: config.workers.keychain.clone(),
808        current_state: t.frontmatter.state.clone(),
809        command: Some(params.command.clone()),
810    };
811    if should_check_claude_compat(root, &params.agent) {
812        check_output_format_supported(&params.command)?;
813    }
814    let child = spawn_worker(&ctx, &params.agent, root)?;
815    let pid = child.id();
816
817    let denial_ctx = if params.agent == "claude" {
818        Some((log_path.clone(), wt_display.clone(), id.clone()))
819    } else {
820        None
821    };
822    let managed = ManagedChild {
823        inner: child,
824        temp_files: vec![sys_file, msg_file],
825        denial_ctx,
826    };
827
828    let pid_path = wt_display.join(".apm-worker.pid");
829    write_pid_file(&pid_path, pid, &id)?;
830
831    messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display()));
832    messages.push(format!("Agent name: {worker_name}"));
833
834    Ok(Some((id, epic_id, managed, pid_path)))
835}
836
837/// If the ticket has dependencies, prepend a dependency context bundle to the
838/// worker prompt content.  Tickets with no dependencies are unchanged.
839fn with_dependency_bundle(root: &Path, depends_on: &[String], config: &Config, content: String) -> String {
840    if depends_on.is_empty() {
841        return content;
842    }
843    let bundle = crate::context::build_dependency_bundle(root, depends_on, config);
844    if bundle.is_empty() {
845        return content;
846    }
847    format!("{bundle}\n{content}")
848}
849
850/// If the ticket belongs to an epic, prepend an epic context bundle to the
851/// worker prompt content.  Tickets without an epic are unchanged.
852fn with_epic_bundle(root: &Path, epic_id: Option<&str>, ticket_id: &str, config: &Config, content: String) -> String {
853    match epic_id {
854        Some(eid) => {
855            let bundle = crate::context::build_epic_bundle(root, eid, ticket_id, config);
856            format!("{bundle}\n{content}")
857        }
858        None => content,
859    }
860}
861
862pub(crate) fn resolve_builtin_instructions(agent: &str, role: &str) -> Option<&'static str> {
863    match (agent, role) {
864        ("claude", "worker") => Some(CLAUDE_WORKER_DEFAULT),
865        ("claude", "spec-writer") => Some(CLAUDE_SPEC_WRITER_DEFAULT),
866        ("mock-happy", "worker") => Some(MOCK_HAPPY_WORKER_DEFAULT),
867        ("mock-happy", "spec-writer") => Some(MOCK_HAPPY_SPEC_WRITER_DEFAULT),
868        ("mock-sad", "worker") => Some(MOCK_SAD_WORKER_DEFAULT),
869        ("mock-sad", "spec-writer") => Some(MOCK_SAD_SPEC_WRITER_DEFAULT),
870        ("mock-random", "worker") => Some(MOCK_RANDOM_WORKER_DEFAULT),
871        ("mock-random", "spec-writer") => Some(MOCK_RANDOM_SPEC_WRITER_DEFAULT),
872        ("debug", "worker") => Some(DEBUG_WORKER_DEFAULT),
873        ("debug", "spec-writer") => Some(DEBUG_SPEC_WRITER_DEFAULT),
874        _ => None,
875    }
876}
877
878fn resolve_system_prompt(
879    root: &Path,
880    transition_instructions: Option<&str>,
881    profile: Option<&WorkerProfileConfig>,
882    workers: &WorkersConfig,
883    agent: &str,
884    role: &str,
885) -> Result<String> {
886    // Level 0: transition.instructions
887    if let Some(path) = transition_instructions {
888        return std::fs::read_to_string(root.join(path))
889            .with_context(|| format!("transition.instructions: file not found: {path}"));
890    }
891    // Level 1: profile.instructions
892    if let Some(p) = profile {
893        if let Some(ref instr_path) = p.instructions {
894            match std::fs::read_to_string(root.join(instr_path)) {
895                Ok(content) => return Ok(content),
896                Err(_) => bail!("[worker_profiles.*].instructions: file not found: {instr_path}"),
897            }
898        }
899    }
900    // Level 2: workers.instructions
901    if let Some(ref instr_path) = workers.instructions {
902        match std::fs::read_to_string(root.join(instr_path)) {
903            Ok(content) => return Ok(content),
904            Err(_) => bail!("[workers].instructions: file not found: {instr_path}"),
905        }
906    }
907    // Level 3: .apm/agents/<agent>/apm.<role>.md
908    let per_agent = root.join(format!(".apm/agents/{agent}/apm.{role}.md"));
909    if per_agent.exists() {
910        if let Ok(content) = std::fs::read_to_string(&per_agent) {
911            return Ok(content);
912        }
913    }
914    // Level 4: built-in default
915    if let Some(s) = resolve_builtin_instructions(agent, role) {
916        return Ok(s.to_string());
917    }
918    // Level 5: hard error
919    bail!(
920        "no instructions found for agent '{agent}' role '{role}': \
921         set [workers].instructions in .apm/config.toml or add \
922         .apm/agents/{agent}/apm.{role}.md"
923    )
924}
925
926fn agent_role_prefix(transition_role_prefix: Option<&str>, profile: Option<&WorkerProfileConfig>, id: &str) -> String {
927    if let Some(prefix) = transition_role_prefix {
928        return prefix.replace("<id>", id);
929    }
930    if let Some(p) = profile {
931        if let Some(ref prefix) = p.role_prefix {
932            return prefix.replace("<id>", id);
933        }
934    }
935    format!("You are a Worker agent assigned to ticket #{id}.")
936}
937
938fn write_pid_file(path: &Path, pid: u32, ticket_id: &str) -> Result<()> {
939    let started_at = chrono::Utc::now().format("%Y-%m-%dT%H:%MZ").to_string();
940    let content = serde_json::json!({
941        "pid": pid,
942        "ticket_id": ticket_id,
943        "started_at": started_at,
944    })
945    .to_string();
946    std::fs::write(path, content)?;
947    Ok(())
948}
949
950fn rand_u16() -> u16 {
951    use std::time::{SystemTime, UNIX_EPOCH};
952    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16
953}
954
955#[cfg(test)]
956mod tests {
957    use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, apply_frontmatter_agent, ManagedChild, DEPRECATION_WARNED, DEPRECATION_MSG, DEPRECATION_TEST_LOCK, emit_deprecation_warning_to};
958    use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy};
959    use std::collections::HashMap;
960
961    fn make_transition(profile: Option<&str>) -> TransitionConfig {
962        TransitionConfig {
963            to: "in_progress".into(),
964            trigger: "command:start".into(),
965            label: String::new(),
966            hint: String::new(),
967            completion: CompletionStrategy::None,
968            focus_section: None,
969            context_section: None,
970            warning: None,
971            profile: profile.map(|s| s.to_string()),
972            instructions: None,
973            role_prefix: None,
974            agent: None,
975            on_failure: None,
976            outcome: None,
977        }
978    }
979
980    fn make_profile(instructions: Option<&str>, role_prefix: Option<&str>) -> WorkerProfileConfig {
981        WorkerProfileConfig {
982            instructions: instructions.map(|s| s.to_string()),
983            role_prefix: role_prefix.map(|s| s.to_string()),
984            ..Default::default()
985        }
986    }
987
988    fn make_workers(command: &str, model: Option<&str>) -> WorkersConfig {
989        WorkersConfig {
990            command: Some(command.to_string()),
991            args: None,
992            model: model.map(|s| s.to_string()),
993            env: HashMap::new(),
994            container: None,
995            keychain: HashMap::new(),
996            agent: None,
997            options: HashMap::new(),
998            instructions: None,
999        }
1000    }
1001
1002    // --- resolve_profile ---
1003
1004    #[test]
1005    fn resolve_profile_returns_profile_when_found() {
1006        let mut config = crate::config::Config {
1007            project: crate::config::ProjectConfig {
1008                name: "test".into(),
1009                description: String::new(),
1010                default_branch: "main".into(),
1011                collaborators: vec![],
1012            },
1013            ticket: Default::default(),
1014            tickets: Default::default(),
1015            workflow: Default::default(),
1016            agents: Default::default(),
1017            worktrees: Default::default(),
1018            sync: Default::default(),
1019            logging: Default::default(),
1020            workers: make_workers("claude", None),
1021            work: Default::default(),
1022            server: Default::default(),
1023            git_host: Default::default(),
1024            worker_profiles: HashMap::new(),
1025            context: Default::default(),
1026            isolation: Default::default(),
1027            load_warnings: vec![],
1028        };
1029        let profile = make_profile(Some(".apm/spec.md"), Some("Spec-Writer for #<id>"));
1030        config.worker_profiles.insert("spec_agent".into(), profile);
1031
1032        let tr = make_transition(Some("spec_agent"));
1033        let mut w = Vec::new();
1034        assert!(resolve_profile(&tr, &config, &mut w).is_some());
1035    }
1036
1037    #[test]
1038    fn resolve_profile_returns_none_for_missing_profile() {
1039        let config = crate::config::Config {
1040            project: crate::config::ProjectConfig {
1041                name: "test".into(),
1042                description: String::new(),
1043                default_branch: "main".into(),
1044                collaborators: vec![],
1045            },
1046            ticket: Default::default(),
1047            tickets: Default::default(),
1048            workflow: Default::default(),
1049            agents: Default::default(),
1050            worktrees: Default::default(),
1051            sync: Default::default(),
1052            logging: Default::default(),
1053            workers: make_workers("claude", None),
1054            work: Default::default(),
1055            server: Default::default(),
1056            git_host: Default::default(),
1057            worker_profiles: HashMap::new(),
1058            context: Default::default(),
1059            isolation: Default::default(),
1060            load_warnings: vec![],
1061        };
1062        let tr = make_transition(Some("nonexistent_profile"));
1063        let mut w = Vec::new();
1064        assert!(resolve_profile(&tr, &config, &mut w).is_none());
1065    }
1066
1067    #[test]
1068    fn resolve_profile_returns_none_when_no_profile_on_transition() {
1069        let config = crate::config::Config {
1070            project: crate::config::ProjectConfig {
1071                name: "test".into(),
1072                description: String::new(),
1073                default_branch: "main".into(),
1074                collaborators: vec![],
1075            },
1076            ticket: Default::default(),
1077            tickets: Default::default(),
1078            workflow: Default::default(),
1079            agents: Default::default(),
1080            worktrees: Default::default(),
1081            sync: Default::default(),
1082            logging: Default::default(),
1083            workers: make_workers("claude", None),
1084            work: Default::default(),
1085            server: Default::default(),
1086            git_host: Default::default(),
1087            worker_profiles: HashMap::new(),
1088            context: Default::default(),
1089            isolation: Default::default(),
1090            load_warnings: vec![],
1091        };
1092        let tr = make_transition(None);
1093        let mut w = Vec::new();
1094        assert!(resolve_profile(&tr, &config, &mut w).is_none());
1095    }
1096
1097    // --- effective_spawn_params ---
1098
1099    #[test]
1100    fn effective_spawn_params_profile_command_overrides_global() {
1101        let workers = make_workers("claude", Some("sonnet"));
1102        let profile = WorkerProfileConfig {
1103            command: Some("my-claude".into()),
1104            ..Default::default()
1105        };
1106        let params = effective_spawn_params(None, Some(&profile), &workers);
1107        assert_eq!(params.command, "my-claude");
1108    }
1109
1110    #[test]
1111    fn effective_spawn_params_falls_back_to_global_command() {
1112        let workers = make_workers("claude", None);
1113        let params = effective_spawn_params(None, None, &workers);
1114        assert_eq!(params.command, "claude");
1115    }
1116
1117    #[test]
1118    fn effective_spawn_params_profile_model_overrides_global() {
1119        let workers = make_workers("claude", Some("sonnet"));
1120        let profile = WorkerProfileConfig {
1121            model: Some("opus".into()),
1122            ..Default::default()
1123        };
1124        let params = effective_spawn_params(None, Some(&profile), &workers);
1125        assert_eq!(params.model.as_deref(), Some("opus"));
1126    }
1127
1128    #[test]
1129    fn effective_spawn_params_falls_back_to_global_model() {
1130        let workers = make_workers("claude", Some("sonnet"));
1131        let params = effective_spawn_params(None, None, &workers);
1132        assert_eq!(params.model.as_deref(), Some("sonnet"));
1133    }
1134
1135    #[test]
1136    fn effective_spawn_params_profile_env_merged_over_global() {
1137        let mut workers = make_workers("claude", None);
1138        workers.env.insert("FOO".into(), "global".into());
1139        workers.env.insert("BAR".into(), "bar".into());
1140
1141        let mut profile_env = HashMap::new();
1142        profile_env.insert("FOO".into(), "profile".into());
1143        let profile = WorkerProfileConfig {
1144            env: profile_env,
1145            ..Default::default()
1146        };
1147        let params = effective_spawn_params(None, Some(&profile), &workers);
1148        assert_eq!(params.env.get("FOO").map(|s| s.as_str()), Some("profile"));
1149        assert_eq!(params.env.get("BAR").map(|s| s.as_str()), Some("bar"));
1150    }
1151
1152    #[test]
1153    fn effective_spawn_params_profile_container_overrides_global() {
1154        let mut workers = make_workers("claude", None);
1155        workers.container = Some("global-image".into());
1156        let profile = WorkerProfileConfig {
1157            container: Some("profile-image".into()),
1158            ..Default::default()
1159        };
1160        let params = effective_spawn_params(None, Some(&profile), &workers);
1161        assert_eq!(params.container.as_deref(), Some("profile-image"));
1162    }
1163
1164    #[test]
1165    fn transition_agent_takes_precedence_over_profile() {
1166        let workers = WorkersConfig::default();
1167        let profile = WorkerProfileConfig { agent: Some("other".into()), ..Default::default() };
1168        let params = effective_spawn_params(Some("custom"), Some(&profile), &workers);
1169        assert_eq!(params.agent, "custom");
1170    }
1171
1172    #[test]
1173    fn effective_agent_defaults_to_claude() {
1174        let workers = WorkersConfig::default();
1175        let params = effective_spawn_params(None, None, &workers);
1176        assert_eq!(params.agent, "claude");
1177    }
1178
1179    // --- resolve_system_prompt ---
1180
1181    #[test]
1182    fn resolve_system_prompt_uses_profile_instructions() {
1183        let dir = tempfile::tempdir().unwrap();
1184        let p = dir.path();
1185        std::fs::create_dir_all(p.join(".apm")).unwrap();
1186        std::fs::write(p.join(".apm/spec.md"), "SPEC WRITER").unwrap();
1187        let profile = make_profile(Some(".apm/spec.md"), None);
1188        let workers = WorkersConfig::default();
1189        assert_eq!(
1190            resolve_system_prompt(p, None, Some(&profile), &workers, "claude", "worker").unwrap(),
1191            "SPEC WRITER"
1192        );
1193    }
1194
1195    #[test]
1196    fn resolve_system_prompt_uses_workers_instructions_when_no_profile() {
1197        let dir = tempfile::tempdir().unwrap();
1198        let p = dir.path();
1199        std::fs::create_dir_all(p.join(".apm")).unwrap();
1200        std::fs::write(p.join(".apm/global.md"), "GLOBAL INSTRUCTIONS").unwrap();
1201        let workers = WorkersConfig {
1202            instructions: Some(".apm/global.md".to_string()),
1203            ..WorkersConfig::default()
1204        };
1205        assert_eq!(
1206            resolve_system_prompt(p, None, None, &workers, "claude", "worker").unwrap(),
1207            "GLOBAL INSTRUCTIONS"
1208        );
1209    }
1210
1211    #[test]
1212    fn resolve_system_prompt_uses_per_agent_file() {
1213        let dir = tempfile::tempdir().unwrap();
1214        let p = dir.path();
1215        std::fs::create_dir_all(p.join(".apm/agents/claude")).unwrap();
1216        std::fs::write(p.join(".apm/agents/claude/apm.worker.md"), "PER AGENT WORKER").unwrap();
1217        let workers = WorkersConfig::default();
1218        assert_eq!(
1219            resolve_system_prompt(p, None, None, &workers, "claude", "worker").unwrap(),
1220            "PER AGENT WORKER"
1221        );
1222    }
1223
1224    #[test]
1225    fn resolve_system_prompt_falls_back_to_builtin_default() {
1226        let dir = tempfile::tempdir().unwrap();
1227        let p = dir.path();
1228        let workers = WorkersConfig::default();
1229        let result = resolve_system_prompt(p, None, None, &workers, "claude", "worker").unwrap();
1230        assert_eq!(result, super::CLAUDE_WORKER_DEFAULT);
1231    }
1232
1233    #[test]
1234    fn resolve_system_prompt_falls_back_to_builtin_spec_writer() {
1235        let dir = tempfile::tempdir().unwrap();
1236        let p = dir.path();
1237        let workers = WorkersConfig::default();
1238        let result = resolve_system_prompt(p, None, None, &workers, "claude", "spec-writer").unwrap();
1239        assert_eq!(result, super::CLAUDE_SPEC_WRITER_DEFAULT);
1240    }
1241
1242    #[test]
1243    fn resolve_system_prompt_errors_for_unknown_agent() {
1244        let dir = tempfile::tempdir().unwrap();
1245        let p = dir.path();
1246        let workers = WorkersConfig::default();
1247        let result = resolve_system_prompt(p, None, None, &workers, "custom-bot", "worker");
1248        assert!(result.is_err());
1249        let msg = result.unwrap_err().to_string();
1250        assert!(msg.contains("custom-bot"), "error should name the agent: {msg}");
1251        assert!(msg.contains("worker"), "error should name the role: {msg}");
1252    }
1253
1254    #[test]
1255    fn resolve_system_prompt_profile_instructions_missing_file_is_error() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let p = dir.path();
1258        let profile = make_profile(Some(".apm/nonexistent.md"), None);
1259        let workers = WorkersConfig::default();
1260        let result = resolve_system_prompt(p, None, Some(&profile), &workers, "claude", "worker");
1261        assert!(result.is_err());
1262        let msg = result.unwrap_err().to_string();
1263        assert!(msg.contains("nonexistent.md"), "error should name the file: {msg}");
1264    }
1265
1266    #[test]
1267    fn resolve_system_prompt_backward_compat() {
1268        let dir = tempfile::tempdir().unwrap();
1269        let p = dir.path();
1270        std::fs::create_dir_all(p.join(".apm")).unwrap();
1271        std::fs::write(p.join(".apm/apm.worker.md"), "LEGACY WORKER CONTENT").unwrap();
1272        let profile = make_profile(Some(".apm/apm.worker.md"), None);
1273        let workers = WorkersConfig::default();
1274        assert_eq!(
1275            resolve_system_prompt(p, None, Some(&profile), &workers, "claude", "worker").unwrap(),
1276            "LEGACY WORKER CONTENT"
1277        );
1278    }
1279
1280    // --- agent_role_prefix ---
1281
1282    #[test]
1283    fn agent_role_prefix_uses_profile_role_prefix() {
1284        let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1285        assert_eq!(
1286            agent_role_prefix(None, Some(&profile), "abc123"),
1287            "You are a Spec-Writer agent assigned to ticket #abc123."
1288        );
1289    }
1290
1291    #[test]
1292    fn agent_role_prefix_falls_back_to_worker_default() {
1293        assert_eq!(
1294            agent_role_prefix(None, None, "abc123"),
1295            "You are a Worker agent assigned to ticket #abc123."
1296        );
1297    }
1298
1299    #[test]
1300    fn agent_role_prefix_transition_takes_precedence_over_profile() {
1301        let profile = make_profile(None, Some("You are a Spec-Writer agent assigned to ticket #<id>."));
1302        assert_eq!(
1303            agent_role_prefix(Some("You are a Custom agent for ticket #<id>."), Some(&profile), "abc123"),
1304            "You are a Custom agent for ticket #abc123."
1305        );
1306    }
1307
1308    // --- transition-level instruction overrides ---
1309
1310    #[test]
1311    fn transition_instructions_takes_precedence_over_profile() {
1312        let dir = tempfile::tempdir().unwrap();
1313        let p = dir.path();
1314        std::fs::create_dir_all(p.join(".apm")).unwrap();
1315        std::fs::write(p.join(".apm/transition.md"), "TRANSITION CONTENT").unwrap();
1316        std::fs::write(p.join(".apm/profile.md"), "PROFILE CONTENT").unwrap();
1317        let profile = make_profile(Some(".apm/profile.md"), None);
1318        let workers = WorkersConfig::default();
1319        assert_eq!(
1320            resolve_system_prompt(p, Some(".apm/transition.md"), Some(&profile), &workers, "claude", "worker").unwrap(),
1321            "TRANSITION CONTENT"
1322        );
1323    }
1324
1325    #[test]
1326    fn transition_instructions_no_profile_required() {
1327        let dir = tempfile::tempdir().unwrap();
1328        let p = dir.path();
1329        std::fs::create_dir_all(p.join(".apm")).unwrap();
1330        std::fs::write(p.join(".apm/transition.md"), "TRANSITION ONLY").unwrap();
1331        let workers = WorkersConfig::default();
1332        assert_eq!(
1333            resolve_system_prompt(p, Some(".apm/transition.md"), None, &workers, "claude", "worker").unwrap(),
1334            "TRANSITION ONLY"
1335        );
1336    }
1337
1338    #[test]
1339    fn epic_filter_keeps_only_matching_tickets() {
1340        use crate::ticket::Ticket;
1341        use std::path::Path;
1342
1343        let make_ticket = |id: &str, epic: Option<&str>| {
1344            let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1345            let raw = format!(
1346                "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1347            );
1348            Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1349        };
1350
1351        let all_tickets = vec![
1352            make_ticket("aaa", Some("epic1")),
1353            make_ticket("bbb", Some("epic2")),
1354            make_ticket("ccc", None),
1355        ];
1356
1357        let epic_id = "epic1";
1358        let filtered: Vec<Ticket> = all_tickets.into_iter()
1359            .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
1360            .collect();
1361
1362        assert_eq!(filtered.len(), 1);
1363        assert_eq!(filtered[0].frontmatter.id, "aaa");
1364    }
1365
1366    #[test]
1367    fn no_epic_filter_keeps_all_tickets() {
1368        use crate::ticket::Ticket;
1369        use std::path::Path;
1370
1371        let make_ticket = |id: &str, epic: Option<&str>| {
1372            let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1373            let raw = format!(
1374                "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}+++\n"
1375            );
1376            Ticket::parse(Path::new("tickets/dummy.md"), &raw).unwrap()
1377        };
1378
1379        let all_tickets: Vec<Ticket> = vec![
1380            make_ticket("aaa", Some("epic1")),
1381            make_ticket("bbb", Some("epic2")),
1382            make_ticket("ccc", None),
1383        ];
1384
1385        let count = all_tickets.len();
1386        let epic_filter: Option<&str> = None;
1387        let filtered: Vec<Ticket> = match epic_filter {
1388            Some(eid) => all_tickets.into_iter()
1389                .filter(|t| t.frontmatter.epic.as_deref() == Some(eid))
1390                .collect(),
1391            None => all_tickets,
1392        };
1393        assert_eq!(filtered.len(), count);
1394    }
1395
1396    // --- spawn worker cwd ---
1397
1398    #[test]
1399    fn spawn_worker_cwd_is_ticket_worktree() {
1400        use std::os::unix::fs::PermissionsExt;
1401
1402        let wt = tempfile::tempdir().unwrap();
1403        let log_dir = tempfile::tempdir().unwrap();
1404        let mock_dir = tempfile::tempdir().unwrap();
1405
1406        // Write mock 'claude' script — reports pwd to a file
1407        let mock_claude = mock_dir.path().join("claude");
1408        let cwd_file = wt.path().join("cwd-output.txt");
1409        let script = format!(concat!(
1410            "#!/bin/sh\n",
1411            "pwd > \"{}\"\n",
1412        ), cwd_file.display());
1413        std::fs::write(&mock_claude, &script).unwrap();
1414        std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1415
1416        let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap();
1417        let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1418
1419        let mut extra_env = HashMap::new();
1420        extra_env.insert(
1421            "PATH".to_string(),
1422            format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1423        );
1424
1425        let ctx = crate::wrapper::WrapperContext {
1426            worker_name: "test-worker".to_string(),
1427            ticket_id: "test-id".to_string(),
1428            ticket_branch: "ticket/test-id".to_string(),
1429            worktree_path: wt.path().to_path_buf(),
1430            system_prompt_file: sys_file.clone(),
1431            user_message_file: msg_file.clone(),
1432            skip_permissions: false,
1433            profile: "default".to_string(),
1434            role_prefix: None,
1435            options: HashMap::new(),
1436            model: None,
1437            log_path: log_dir.path().join("worker.log"),
1438            container: None,
1439            extra_env,
1440            root: wt.path().to_path_buf(),
1441            keychain: HashMap::new(),
1442            current_state: "in_progress".to_string(),
1443            command: None,
1444        };
1445
1446        let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1447        let mut child = wrapper.spawn(&ctx).unwrap();
1448        child.wait().unwrap();
1449        let _ = std::fs::remove_file(&sys_file);
1450        let _ = std::fs::remove_file(&msg_file);
1451
1452        let cwd_out = std::fs::read_to_string(&cwd_file)
1453            .expect("cwd-output.txt not written — mock claude did not run in expected cwd");
1454        let expected = wt.path().canonicalize().unwrap();
1455        assert_eq!(
1456            cwd_out.trim(),
1457            expected.to_str().unwrap(),
1458            "spawned worker CWD must equal the ticket worktree path"
1459        );
1460    }
1461
1462    // --- check_output_format_supported ---
1463
1464    #[test]
1465    fn check_output_format_supported_passes_when_flag_present() {
1466        use std::os::unix::fs::PermissionsExt;
1467        let dir = tempfile::tempdir().unwrap();
1468        let bin = dir.path().join("fake-claude");
1469        std::fs::write(&bin, "#!/bin/sh\necho '--output-format stream-json'\n").unwrap();
1470        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1471        assert!(check_output_format_supported(bin.to_str().unwrap()).is_ok());
1472    }
1473
1474    #[test]
1475    fn check_output_format_supported_errors_when_flag_absent() {
1476        use std::os::unix::fs::PermissionsExt;
1477        let dir = tempfile::tempdir().unwrap();
1478        let bin = dir.path().join("old-claude");
1479        std::fs::write(&bin, "#!/bin/sh\necho 'Usage: old-claude [options]'\n").unwrap();
1480        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1481        let err = check_output_format_supported(bin.to_str().unwrap()).unwrap_err();
1482        let msg = err.to_string();
1483        assert!(
1484            msg.contains("--output-format"),
1485            "error message must name the missing flag: {msg}"
1486        );
1487        assert!(
1488            msg.contains(bin.to_str().unwrap()),
1489            "error message must include binary path: {msg}"
1490        );
1491    }
1492
1493    // --- APM env vars on spawned process ---
1494
1495    #[test]
1496    fn claude_wrapper_sets_apm_env_vars() {
1497        use std::os::unix::fs::PermissionsExt;
1498
1499        let wt = tempfile::tempdir().unwrap();
1500        let log_dir = tempfile::tempdir().unwrap();
1501        let mock_dir = tempfile::tempdir().unwrap();
1502        let env_output = wt.path().join("env-output.txt");
1503
1504        // Mock 'claude' writes all env vars to a file then exits
1505        let mock_claude = mock_dir.path().join("claude");
1506        let script = format!(
1507            "#!/bin/sh\nprintenv > \"{}\"\n",
1508            env_output.display()
1509        );
1510        std::fs::write(&mock_claude, &script).unwrap();
1511        std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1512
1513        let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
1514        let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1515
1516        let mut extra_env = HashMap::new();
1517        extra_env.insert(
1518            "PATH".to_string(),
1519            format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1520        );
1521
1522        let ctx = crate::wrapper::WrapperContext {
1523            worker_name: "test-worker".to_string(),
1524            ticket_id: "abc123".to_string(),
1525            ticket_branch: "ticket/abc123-some-feature".to_string(),
1526            worktree_path: wt.path().to_path_buf(),
1527            system_prompt_file: sys_file.clone(),
1528            user_message_file: msg_file.clone(),
1529            skip_permissions: false,
1530            profile: "my-profile".to_string(),
1531            role_prefix: None,
1532            options: HashMap::new(),
1533            model: None,
1534            log_path: log_dir.path().join("worker.log"),
1535            container: None,
1536            extra_env,
1537            root: wt.path().to_path_buf(),
1538            keychain: HashMap::new(),
1539            current_state: "in_progress".to_string(),
1540            command: None,
1541        };
1542
1543        let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1544        let mut child = wrapper.spawn(&ctx).unwrap();
1545        child.wait().unwrap();
1546        let _ = std::fs::remove_file(&sys_file);
1547        let _ = std::fs::remove_file(&msg_file);
1548
1549        let env_content = std::fs::read_to_string(&env_output)
1550            .expect("env-output.txt not written — mock claude did not run");
1551
1552        assert!(env_content.contains("APM_AGENT_NAME=test-worker"), "missing APM_AGENT_NAME\n{env_content}");
1553        assert!(env_content.contains("APM_TICKET_ID=abc123"), "missing APM_TICKET_ID\n{env_content}");
1554        assert!(env_content.contains("APM_TICKET_BRANCH=ticket/abc123-some-feature"), "missing APM_TICKET_BRANCH\n{env_content}");
1555        assert!(env_content.contains("APM_TICKET_WORKTREE="), "missing APM_TICKET_WORKTREE\n{env_content}");
1556        assert!(env_content.contains("APM_SYSTEM_PROMPT_FILE="), "missing APM_SYSTEM_PROMPT_FILE\n{env_content}");
1557        assert!(env_content.contains("APM_USER_MESSAGE_FILE="), "missing APM_USER_MESSAGE_FILE\n{env_content}");
1558        assert!(env_content.contains("APM_SKIP_PERMISSIONS=0"), "missing APM_SKIP_PERMISSIONS\n{env_content}");
1559        assert!(env_content.contains("APM_PROFILE=my-profile"), "missing APM_PROFILE\n{env_content}");
1560        assert!(env_content.contains("APM_WRAPPER_VERSION=1"), "missing APM_WRAPPER_VERSION\n{env_content}");
1561        assert!(env_content.contains("APM_BIN="), "missing APM_BIN\n{env_content}");
1562
1563        // APM_BIN must point to an existing file
1564        if let Some(line) = env_content.lines().find(|l| l.starts_with("APM_BIN=")) {
1565            let path = line.trim_start_matches("APM_BIN=");
1566            assert!(std::path::Path::new(path).exists(), "APM_BIN path does not exist: {path}");
1567        }
1568    }
1569
1570    // --- temp file cleanup ---
1571
1572    #[test]
1573    fn temp_files_removed_after_child_exits() {
1574        use std::os::unix::fs::PermissionsExt;
1575
1576        let wt = tempfile::tempdir().unwrap();
1577        let log_dir = tempfile::tempdir().unwrap();
1578        let mock_dir = tempfile::tempdir().unwrap();
1579
1580        // Mock 'claude' that just exits immediately
1581        let mock_claude = mock_dir.path().join("claude");
1582        std::fs::write(&mock_claude, "#!/bin/sh\nexit 0\n").unwrap();
1583        std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1584
1585        let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap();
1586        let msg_file = crate::wrapper::write_temp_file("msg", "message").unwrap();
1587
1588        assert!(sys_file.exists(), "sys_file should exist before spawn");
1589        assert!(msg_file.exists(), "msg_file should exist before spawn");
1590
1591        let mut extra_env = HashMap::new();
1592        extra_env.insert(
1593            "PATH".to_string(),
1594            format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1595        );
1596
1597        let ctx = crate::wrapper::WrapperContext {
1598            worker_name: "test".to_string(),
1599            ticket_id: "test123".to_string(),
1600            ticket_branch: "ticket/test123".to_string(),
1601            worktree_path: wt.path().to_path_buf(),
1602            system_prompt_file: sys_file.clone(),
1603            user_message_file: msg_file.clone(),
1604            skip_permissions: false,
1605            profile: "default".to_string(),
1606            role_prefix: None,
1607            options: HashMap::new(),
1608            model: None,
1609            log_path: log_dir.path().join("worker.log"),
1610            container: None,
1611            extra_env,
1612            root: wt.path().to_path_buf(),
1613            keychain: HashMap::new(),
1614            current_state: "in_progress".to_string(),
1615            command: None,
1616        };
1617
1618        let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1619        let child = wrapper.spawn(&ctx).unwrap();
1620
1621        let mut managed = ManagedChild {
1622            inner: child,
1623            temp_files: vec![sys_file.clone(), msg_file.clone()],
1624            denial_ctx: None,
1625        };
1626        managed.inner.wait().unwrap();
1627        drop(managed);
1628
1629        assert!(!sys_file.exists(), "sys_file should be removed after ManagedChild is dropped");
1630        assert!(!msg_file.exists(), "msg_file should be removed after ManagedChild is dropped");
1631    }
1632
1633    // --- agent/options resolution ---
1634
1635    #[test]
1636    fn resolution_agent_profile_overrides_global() {
1637        let workers = WorkersConfig { agent: Some("codex".into()), ..Default::default() };
1638        let profile = WorkerProfileConfig { agent: Some("mock-happy".into()), ..Default::default() };
1639        let params = effective_spawn_params(None, Some(&profile), &workers);
1640        assert_eq!(params.agent, "mock-happy");
1641    }
1642
1643    #[test]
1644    fn resolution_agent_falls_back_to_claude() {
1645        let params = effective_spawn_params(None, None, &WorkersConfig::default());
1646        assert_eq!(params.agent, "claude");
1647    }
1648
1649    #[test]
1650    fn resolution_options_merge() {
1651        let mut workers = WorkersConfig { agent: Some("claude".into()), ..Default::default() };
1652        workers.options.insert("model".into(), "opus".into());
1653        workers.options.insert("timeout".into(), "30".into());
1654        let mut profile_opts = HashMap::new();
1655        profile_opts.insert("model".into(), "sonnet".into());
1656        let profile = WorkerProfileConfig { options: profile_opts, ..Default::default() };
1657        let params = effective_spawn_params(None, Some(&profile), &workers);
1658        assert_eq!(params.options.get("model").map(|s| s.as_str()), Some("sonnet"), "profile model should override workers model");
1659        assert_eq!(params.options.get("timeout").map(|s| s.as_str()), Some("30"), "non-overlapping key should survive");
1660    }
1661
1662    #[test]
1663    fn deprecation_warning_writes_to_stream_once() {
1664        let _guard = DEPRECATION_TEST_LOCK.lock().unwrap();
1665        DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst);
1666
1667        // Capture what would otherwise go to stderr — proves the message hits
1668        // the writer (i.e. stderr in production), not just an in-memory log.
1669        let mut buf: Vec<u8> = Vec::new();
1670        emit_deprecation_warning_to(&mut buf);
1671        emit_deprecation_warning_to(&mut buf);
1672
1673        let captured = String::from_utf8(buf).unwrap();
1674        let count = captured.matches(DEPRECATION_MSG).count();
1675        assert_eq!(count, 1, "deprecated message should appear exactly once on the writer, found {count}\n{captured}");
1676    }
1677
1678    #[test]
1679    fn deprecation_warning_triggered_by_legacy_workers_config() {
1680        let _guard = DEPRECATION_TEST_LOCK.lock().unwrap();
1681        DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst);
1682
1683        let workers = WorkersConfig { command: Some("claude".into()), ..Default::default() };
1684        effective_spawn_params(None, None, &workers);
1685
1686        assert!(
1687            DEPRECATION_WARNED.load(std::sync::atomic::Ordering::SeqCst),
1688            "legacy [workers].command must trigger the deprecation warning"
1689        );
1690    }
1691
1692    #[test]
1693    fn legacy_model_forwarded_to_ctx() {
1694        let workers = WorkersConfig { model: Some("opus".into()), ..Default::default() };
1695        let params = effective_spawn_params(None, None, &workers);
1696        assert_eq!(params.model.as_deref(), Some("opus"));
1697    }
1698
1699    #[test]
1700    fn options_model_takes_precedence_over_legacy() {
1701        let mut workers = WorkersConfig { model: Some("opus".into()), agent: Some("claude".into()), ..Default::default() };
1702        workers.options.insert("model".into(), "sonnet".into());
1703        let params = effective_spawn_params(None, None, &workers);
1704        assert_eq!(params.model.as_deref(), Some("sonnet"));
1705    }
1706
1707    // --- APM_OPT_ env vars ---
1708
1709    #[test]
1710    fn apm_opt_env_vars_set() {
1711        use std::os::unix::fs::PermissionsExt;
1712
1713        let wt = tempfile::tempdir().unwrap();
1714        let log_dir = tempfile::tempdir().unwrap();
1715        let mock_dir = tempfile::tempdir().unwrap();
1716        let env_output = wt.path().join("env-output.txt");
1717
1718        let mock_claude = mock_dir.path().join("claude");
1719        let script = format!("#!/bin/sh\nprintenv > \"{}\"\n", env_output.display());
1720        std::fs::write(&mock_claude, &script).unwrap();
1721        std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1722
1723        let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
1724        let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
1725
1726        let mut extra_env = HashMap::new();
1727        extra_env.insert(
1728            "PATH".to_string(),
1729            format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()),
1730        );
1731
1732        let mut options = HashMap::new();
1733        options.insert("model".to_string(), "sonnet".to_string());
1734
1735        let ctx = crate::wrapper::WrapperContext {
1736            worker_name: "test-worker".to_string(),
1737            ticket_id: "abc123".to_string(),
1738            ticket_branch: "ticket/abc123".to_string(),
1739            worktree_path: wt.path().to_path_buf(),
1740            system_prompt_file: sys_file.clone(),
1741            user_message_file: msg_file.clone(),
1742            skip_permissions: false,
1743            profile: "default".to_string(),
1744            role_prefix: None,
1745            options,
1746            model: None,
1747            log_path: log_dir.path().join("worker.log"),
1748            container: None,
1749            extra_env,
1750            root: wt.path().to_path_buf(),
1751            keychain: HashMap::new(),
1752            current_state: "in_progress".to_string(),
1753            command: None,
1754        };
1755
1756        let wrapper = crate::wrapper::resolve_builtin("claude").unwrap();
1757        let mut child = wrapper.spawn(&ctx).unwrap();
1758        child.wait().unwrap();
1759        let _ = std::fs::remove_file(&sys_file);
1760        let _ = std::fs::remove_file(&msg_file);
1761
1762        let env_content = std::fs::read_to_string(&env_output)
1763            .expect("env-output.txt not written");
1764
1765        assert!(env_content.contains("APM_OPT_MODEL=sonnet"), "APM_OPT_MODEL=sonnet must be set\n{env_content}");
1766    }
1767
1768    // --- apply_frontmatter_agent ---
1769
1770    fn make_frontmatter_with_agent(agent: Option<&str>, overrides: &[(&str, &str)]) -> crate::ticket_fmt::Frontmatter {
1771        let agent_line = agent.map(|a| format!("agent = \"{a}\"\n")).unwrap_or_default();
1772        let overrides_section = if overrides.is_empty() {
1773            String::new()
1774        } else {
1775            let pairs: Vec<String> = overrides.iter()
1776                .map(|(k, v)| format!("{k} = \"{v}\""))
1777                .collect();
1778            format!("[agent_overrides]\n{}\n", pairs.join("\n"))
1779        };
1780        let toml_str = format!("id = \"t\"\ntitle = \"T\"\nstate = \"new\"\n{agent_line}{overrides_section}");
1781        toml::from_str(&toml_str).unwrap()
1782    }
1783
1784    #[test]
1785    fn apply_fm_profile_override_wins() {
1786        let fm = make_frontmatter_with_agent(Some("mock-sad"), &[("impl_agent", "mock-happy")]);
1787        let mut agent = "claude".to_string();
1788        apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1789        assert_eq!(agent, "mock-happy");
1790    }
1791
1792    #[test]
1793    fn apply_fm_agent_field_wins_when_no_profile_match() {
1794        let fm = make_frontmatter_with_agent(Some("mock-sad"), &[]);
1795        let mut agent = "claude".to_string();
1796        apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1797        assert_eq!(agent, "mock-sad");
1798    }
1799
1800    #[test]
1801    fn apply_fm_profile_override_beats_agent_field() {
1802        let fm = make_frontmatter_with_agent(Some("mock-random"), &[("impl_agent", "claude")]);
1803        let mut agent = "other".to_string();
1804        apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1805        assert_eq!(agent, "claude");
1806    }
1807
1808    #[test]
1809    fn apply_fm_no_fields_unchanged() {
1810        let fm = make_frontmatter_with_agent(None, &[]);
1811        let mut agent = "claude".to_string();
1812        apply_frontmatter_agent(&mut agent, &fm, "impl_agent");
1813        assert_eq!(agent, "claude");
1814    }
1815
1816    // --- mock wrapper integration tests ---
1817
1818    fn find_apm_bin() -> Option<String> {
1819        // 1. Explicit override wins
1820        if let Ok(v) = std::env::var("APM_BIN") {
1821            if !v.is_empty() && std::path::Path::new(&v).exists() {
1822                return Some(v);
1823            }
1824        }
1825        // 2. Derive from the test binary path.
1826        //    current_exe() -> <workspace>/target/{profile}/deps/apm_core-<hash>
1827        //    two parents up -> <workspace>/target/{profile}/
1828        //    sibling "apm"  -> <workspace>/target/{profile}/apm
1829        if let Ok(exe) = std::env::current_exe() {
1830            if let Some(target_dir) = exe.parent().and_then(|p| p.parent()) {
1831                let candidate = target_dir.join("apm");
1832                if candidate.is_file() {
1833                    return Some(candidate.to_string_lossy().into_owned());
1834                }
1835            }
1836        }
1837        None
1838    }
1839
1840    fn make_mock_project(root: &std::path::Path, ticket_state: &str, ticket_id: &str) {
1841        use std::fs;
1842
1843        fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
1844        fs::create_dir_all(root.join("tickets")).unwrap();
1845
1846        fs::write(root.join(".apm/config.toml"), r#"
1847[project]
1848name = "test-project"
1849default_branch = "main"
1850
1851[workers]
1852agent = "mock-happy"
1853
1854[tickets]
1855dir = "tickets"
1856"#).unwrap();
1857
1858        fs::write(root.join(".apm/workflow.toml"), r#"
1859[[workflow.states]]
1860id = "in_design"
1861label = "In Design"
1862actionable = ["agent"]
1863instructions = ".apm/apm.spec-writer.md"
1864
1865  [[workflow.states.transitions]]
1866  to = "specd"
1867  trigger = "manual"
1868  outcome = "success"
1869
1870  [[workflow.states.transitions]]
1871  to = "closed"
1872  trigger = "manual"
1873  outcome = "cancelled"
1874
1875[[workflow.states]]
1876id = "specd"
1877label = "Specd"
1878actionable = ["supervisor"]
1879satisfies_deps = true
1880worker_end = true
1881
1882  [[workflow.states.transitions]]
1883  to = "in_progress"
1884  trigger = "manual"
1885  outcome = "success"
1886
1887  [[workflow.states.transitions]]
1888  to = "closed"
1889  trigger = "manual"
1890  outcome = "cancelled"
1891
1892[[workflow.states]]
1893id = "in_progress"
1894label = "In Progress"
1895instructions = ".apm/apm.worker.md"
1896
1897  [[workflow.states.transitions]]
1898  to = "implemented"
1899  trigger = "manual"
1900  outcome = "success"
1901
1902  [[workflow.states.transitions]]
1903  to = "closed"
1904  trigger = "manual"
1905  outcome = "cancelled"
1906
1907[[workflow.states]]
1908id = "implemented"
1909label = "Implemented"
1910actionable = ["supervisor"]
1911satisfies_deps = true
1912worker_end = true
1913terminal = false
1914
1915  [[workflow.states.transitions]]
1916  to = "closed"
1917  trigger = "manual"
1918  outcome = "cancelled"
1919
1920[[workflow.states]]
1921id = "closed"
1922label = "Closed"
1923terminal = true
1924"#).unwrap();
1925
1926        fs::write(root.join(".apm/apm.worker.md"), "Worker instructions.").unwrap();
1927        fs::write(root.join(".apm/apm.spec-writer.md"), "Spec writer instructions.").unwrap();
1928
1929        let ticket_content = format!(r#"+++
1930id = "{ticket_id}"
1931title = "Test Ticket"
1932state = "{ticket_state}"
1933priority = 0
1934effort = 5
1935risk = 3
1936author = "test"
1937owner = "test"
1938branch = "ticket/{ticket_id}-test"
1939created_at = "2026-01-01T00:00:00Z"
1940updated_at = "2026-01-01T00:00:00Z"
1941+++
1942
1943## Spec
1944
1945### Problem
1946
1947Original problem.
1948
1949### Acceptance criteria
1950
1951- [ ] Some criterion
1952
1953### Out of scope
1954
1955Nothing.
1956
1957### Approach
1958
1959Some approach.
1960
1961### Open questions
1962
1963### Amendment requests
1964
1965### Code review
1966
1967## History
1968
1969| When | From | To | By |
1970|------|------|----|----|
1971"#);
1972        fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
1973
1974        std::process::Command::new("git")
1975            .arg("init")
1976            .current_dir(root)
1977            .output()
1978            .unwrap();
1979        std::process::Command::new("git")
1980            .args(["config", "user.email", "test@test.com"])
1981            .current_dir(root)
1982            .output()
1983            .unwrap();
1984        std::process::Command::new("git")
1985            .args(["config", "user.name", "Test"])
1986            .current_dir(root)
1987            .output()
1988            .unwrap();
1989        // Create main branch with config files
1990        std::process::Command::new("git")
1991            .args(["add", ".apm"])
1992            .current_dir(root)
1993            .output()
1994            .unwrap();
1995        std::process::Command::new("git")
1996            .args(["commit", "-m", "initial commit", "--allow-empty"])
1997            .current_dir(root)
1998            .output()
1999            .unwrap();
2000        // Create the ticket branch and commit the ticket there
2001        let branch_name = format!("ticket/{ticket_id}-test");
2002        std::process::Command::new("git")
2003            .args(["checkout", "-b", &branch_name])
2004            .current_dir(root)
2005            .output()
2006            .unwrap();
2007        std::process::Command::new("git")
2008            .args(["add", &format!("tickets/{ticket_id}-test.md")])
2009            .current_dir(root)
2010            .output()
2011            .unwrap();
2012        std::process::Command::new("git")
2013            .args(["commit", "-m", &format!("ticket({ticket_id}): created")])
2014            .current_dir(root)
2015            .output()
2016            .unwrap();
2017        // Switch back to main
2018        std::process::Command::new("git")
2019            .args(["checkout", "main"])
2020            .current_dir(root)
2021            .output()
2022            .unwrap();
2023    }
2024
2025    fn make_wrapper_ctx_for_mock(
2026        project_root: &std::path::Path,
2027        ticket_id: &str,
2028        ticket_state: &str,
2029        apm_bin: &str,
2030        log_path: std::path::PathBuf,
2031    ) -> crate::wrapper::WrapperContext {
2032        let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
2033        let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
2034        let mut options = HashMap::new();
2035        options.insert("apm_bin".to_string(), apm_bin.to_string());
2036        crate::wrapper::WrapperContext {
2037            worker_name: "test-worker".to_string(),
2038            ticket_id: ticket_id.to_string(),
2039            ticket_branch: format!("ticket/{ticket_id}-test"),
2040            worktree_path: project_root.to_path_buf(),
2041            system_prompt_file: sys_file,
2042            user_message_file: msg_file,
2043            skip_permissions: false,
2044            profile: "default".to_string(),
2045            role_prefix: None,
2046            options,
2047            model: None,
2048            log_path,
2049            container: None,
2050            extra_env: HashMap::new(),
2051            root: project_root.to_path_buf(),
2052            keychain: HashMap::new(),
2053            current_state: ticket_state.to_string(),
2054            command: None,
2055        }
2056    }
2057
2058    #[test]
2059    fn mock_happy_spec_mode_transitions_to_specd() {
2060        let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2061        let dir = tempfile::tempdir().unwrap();
2062        let root = dir.path();
2063        make_mock_project(root, "in_design", "aaaa0001");
2064        let log_path = root.join("test-worker.log");
2065        let ctx = make_wrapper_ctx_for_mock(root, "aaaa0001", "in_design", &apm_bin, log_path.clone());
2066        let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2067        let mut child = wrapper.spawn(&ctx).unwrap();
2068        child.wait().unwrap();
2069
2070        let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2071        // Read ticket from the ticket branch (where apm commits changes)
2072        let ticket_from_branch = {
2073            let out = std::process::Command::new("git")
2074                .args(["show", "ticket/aaaa0001-test:tickets/aaaa0001-test.md"])
2075                .current_dir(root)
2076                .output()
2077                .unwrap();
2078            String::from_utf8_lossy(&out.stdout).to_string()
2079        };
2080        assert!(ticket_from_branch.contains("state = \"specd\""),
2081            "ticket should be in specd state\nticket_from_branch: {ticket_from_branch}\nlog: {log_content}");
2082        assert!(ticket_from_branch.contains("### Problem"),
2083            "ticket should have Problem section\n{ticket_from_branch}");
2084        assert!(ticket_from_branch.contains("effort = 1"),
2085            "effort should be 1\n{ticket_from_branch}");
2086        assert!(ticket_from_branch.contains("risk = 1"),
2087            "risk should be 1\n{ticket_from_branch}");
2088    }
2089
2090    #[test]
2091    fn mock_happy_zero_success_transitions_returns_err() {
2092        use std::fs;
2093        let dir = tempfile::tempdir().unwrap();
2094        let root = dir.path();
2095
2096        fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
2097        fs::create_dir_all(root.join("tickets")).unwrap();
2098        fs::write(root.join(".apm/config.toml"), r#"
2099[project]
2100name = "test"
2101default_branch = "main"
2102[workers]
2103agent = "mock-happy"
2104[tickets]
2105dir = "tickets"
2106"#).unwrap();
2107        fs::write(root.join(".apm/workflow.toml"), r#"
2108[[workflow.states]]
2109id = "in_design"
2110label = "In Design"
2111actionable = ["agent"]
2112
2113  [[workflow.states.transitions]]
2114  to = "closed"
2115  trigger = "manual"
2116  outcome = "needs_input"
2117
2118[[workflow.states]]
2119id = "closed"
2120label = "Closed"
2121terminal = true
2122"#).unwrap();
2123        fs::write(root.join(".apm/apm.worker.md"), "instructions").unwrap();
2124        fs::write(root.join(".apm/apm.spec-writer.md"), "instructions").unwrap();
2125        let ticket_content = r#"+++
2126id = "aaaa0002"
2127title = "Test"
2128state = "in_design"
2129priority = 0
2130effort = 5
2131risk = 3
2132author = "test"
2133owner = "test"
2134branch = "ticket/aaaa0002-test"
2135created_at = "2026-01-01T00:00:00Z"
2136updated_at = "2026-01-01T00:00:00Z"
2137+++
2138
2139## Spec
2140
2141### Problem
2142
2143### Acceptance criteria
2144
2145### Out of scope
2146
2147### Approach
2148
2149## History
2150
2151| When | From | To | By |
2152|------|------|----|----|
2153"#;
2154        fs::write(root.join("tickets/aaaa0002-test.md"), ticket_content).unwrap();
2155        std::process::Command::new("git").args(["init"]).current_dir(root).output().unwrap();
2156        std::process::Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap();
2157        std::process::Command::new("git").args(["config", "user.name", "T"]).current_dir(root).output().unwrap();
2158        std::process::Command::new("git").args(["add", "."]).current_dir(root).output().unwrap();
2159        std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(root).output().unwrap();
2160
2161        let log_path = root.join("test.log");
2162        let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2163        let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2164        let ctx = crate::wrapper::WrapperContext {
2165            worker_name: "test".to_string(),
2166            ticket_id: "aaaa0002".to_string(),
2167            ticket_branch: "ticket/aaaa0002-test".to_string(),
2168            worktree_path: root.to_path_buf(),
2169            system_prompt_file: sys_file,
2170            user_message_file: msg_file,
2171            skip_permissions: false,
2172            profile: "default".to_string(),
2173            role_prefix: None,
2174            options: HashMap::new(),
2175            model: None,
2176            log_path,
2177            container: None,
2178            extra_env: HashMap::new(),
2179            root: root.to_path_buf(),
2180            keychain: HashMap::new(),
2181            current_state: "in_design".to_string(),
2182            command: None,
2183        };
2184        let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2185        let result = wrapper.spawn(&ctx);
2186        assert!(result.is_err(), "mock-happy should return Err when no success transitions");
2187        let msg = result.unwrap_err().to_string();
2188        assert!(msg.contains("no success-outcome transition"), "error should mention no success transition: {msg}");
2189    }
2190
2191    #[test]
2192    fn mock_sad_transitions_to_non_success_state() {
2193        let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2194        let dir = tempfile::tempdir().unwrap();
2195        let root = dir.path();
2196        make_mock_project(root, "in_design", "aaaa0003");
2197        let log_path = root.join("test.log");
2198        let ctx = make_wrapper_ctx_for_mock(root, "aaaa0003", "in_design", &apm_bin, log_path.clone());
2199        let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2200        let mut child = wrapper.spawn(&ctx).unwrap();
2201        child.wait().unwrap();
2202
2203        let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2204        let out = std::process::Command::new("git")
2205            .args(["show", "ticket/aaaa0003-test:tickets/aaaa0003-test.md"])
2206            .current_dir(root)
2207            .output()
2208            .unwrap();
2209        let ticket_from_branch = String::from_utf8_lossy(&out.stdout).to_string();
2210        assert!(!ticket_from_branch.contains("state = \"specd\""),
2211            "mock-sad should NOT transition to specd\n{ticket_from_branch}\nlog: {log_content}");
2212        // Should have transitioned to some other state
2213        assert!(ticket_from_branch.contains("state = \"closed\"") || ticket_from_branch.contains("state = \"in_design\""),
2214            "mock-sad should transition to a non-success state\n{ticket_from_branch}\nlog: {log_content}");
2215    }
2216
2217    #[test]
2218    fn mock_sad_seed_reproducibility() {
2219        let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2220
2221        let run_mock_sad = |ticket_id: &str, seed: &str| -> String {
2222            let dir = tempfile::tempdir().unwrap();
2223            let root = dir.path();
2224            make_mock_project(root, "in_design", ticket_id);
2225            let log_path = root.join("test.log");
2226            let mut options = HashMap::new();
2227            options.insert("apm_bin".to_string(), apm_bin.clone());
2228            options.insert("seed".to_string(), seed.to_string());
2229            let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2230            let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2231            let ctx = crate::wrapper::WrapperContext {
2232                worker_name: "test".to_string(),
2233                ticket_id: ticket_id.to_string(),
2234                ticket_branch: format!("ticket/{ticket_id}-test"),
2235                worktree_path: root.to_path_buf(),
2236                system_prompt_file: sys_file,
2237                user_message_file: msg_file,
2238                skip_permissions: false,
2239                profile: "default".to_string(),
2240                role_prefix: None,
2241                options,
2242                model: None,
2243                log_path,
2244                container: None,
2245                extra_env: HashMap::new(),
2246                root: root.to_path_buf(),
2247                keychain: HashMap::new(),
2248                current_state: "in_design".to_string(),
2249            command: None,
2250            };
2251            let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2252            let mut child = wrapper.spawn(&ctx).unwrap();
2253            child.wait().unwrap();
2254
2255            // Read state from ticket branch (where apm commits changes)
2256            let git_content = {
2257                let o = std::process::Command::new("git")
2258                    .args(["show", &format!("ticket/{ticket_id}-test:tickets/{ticket_id}-test.md")])
2259                    .current_dir(root)
2260                    .output()
2261                    .unwrap();
2262                String::from_utf8_lossy(&o.stdout).to_string()
2263            };
2264            for line in git_content.lines() {
2265                if line.starts_with("state = ") {
2266                    return line.to_string();
2267                }
2268            }
2269            "unknown".to_string()
2270        };
2271
2272        let state1 = run_mock_sad("aaaa000a", "42");
2273        let state2 = run_mock_sad("aaaa000b", "42");
2274        assert_eq!(state1, state2, "mock-sad with same seed should pick same target state");
2275    }
2276
2277    #[test]
2278    fn debug_does_not_change_state() {
2279        let dir = tempfile::tempdir().unwrap();
2280        let root = dir.path();
2281        make_mock_project(root, "in_design", "aaaa0005");
2282        let log_path = root.join("test.log");
2283        let sys_file = crate::wrapper::write_temp_file("sys", "debug-system-prompt-unique-text").unwrap();
2284        let msg_file = crate::wrapper::write_temp_file("msg", "debug-message").unwrap();
2285        let ctx = crate::wrapper::WrapperContext {
2286            worker_name: "test-worker".to_string(),
2287            ticket_id: "aaaa0005".to_string(),
2288            ticket_branch: "ticket/aaaa0005-test".to_string(),
2289            worktree_path: root.to_path_buf(),
2290            system_prompt_file: sys_file,
2291            user_message_file: msg_file,
2292            skip_permissions: false,
2293            profile: "default".to_string(),
2294            role_prefix: None,
2295            options: HashMap::new(),
2296            model: None,
2297            log_path: log_path.clone(),
2298            container: None,
2299            extra_env: HashMap::new(),
2300            root: root.to_path_buf(),
2301            keychain: HashMap::new(),
2302            current_state: "in_design".to_string(),
2303            command: None,
2304        };
2305        let wrapper = crate::wrapper::resolve_builtin("debug").unwrap();
2306        let mut child = wrapper.spawn(&ctx).unwrap();
2307        child.wait().unwrap();
2308
2309        // State should still be in_design (debug doesn't commit or transition)
2310        // Read from the ticket branch (HEAD of main won't have the ticket)
2311        let git_content = {
2312            let o = std::process::Command::new("git")
2313                .args(["show", "ticket/aaaa0005-test:tickets/aaaa0005-test.md"])
2314                .current_dir(root)
2315                .output()
2316                .unwrap();
2317            String::from_utf8_lossy(&o.stdout).to_string()
2318        };
2319        assert!(git_content.contains("state = \"in_design\""),
2320            "debug should not change ticket state\n{git_content}");
2321
2322        // Log file should contain APM env vars and system prompt text
2323        let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2324        assert!(log_content.contains("APM_TICKET_ID"),
2325            "log should contain APM_TICKET_ID\n{log_content}");
2326        assert!(log_content.contains("debug-system-prompt-unique-text"),
2327            "log should contain system prompt text\n{log_content}");
2328        assert!(log_content.contains("\"type\":\"tool_use\""),
2329            "log should contain tool_use JSONL\n{log_content}");
2330    }
2331}