Skip to main content

apm_core/
start.rs

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