Skip to main content

apm_core/
start.rs

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