Skip to main content

apm_core/
start.rs

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