Skip to main content

apm_core/
start.rs

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