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 = "question"
2080  trigger = "manual"
2081  outcome = "needs_input"
2082
2083[[workflow.states]]
2084id = "question"
2085label = "Question"
2086
2087[[workflow.states]]
2088id = "specd"
2089label = "Specd"
2090satisfies_deps = true
2091worker_end = true
2092
2093  [[workflow.states.transitions]]
2094  to = "in_progress"
2095  trigger = "manual"
2096  outcome = "success"
2097
2098[[workflow.states]]
2099id = "in_progress"
2100label = "In Progress"
2101
2102  [[workflow.states.transitions]]
2103  to = "implemented"
2104  trigger = "manual"
2105  outcome = "success"
2106
2107[[workflow.states]]
2108id = "implemented"
2109label = "Implemented"
2110satisfies_deps = true
2111worker_end = true
2112terminal = false
2113
2114[[workflow.states]]
2115id = "closed"
2116label = "Closed"
2117terminal = true
2118"#).unwrap();
2119
2120        fs::write(root.join(".apm/apm.worker.md"), "Worker instructions.").unwrap();
2121        fs::write(root.join(".apm/apm.spec-writer.md"), "Spec writer instructions.").unwrap();
2122
2123        let ticket_content = format!(r#"+++
2124id = "{ticket_id}"
2125title = "Test Ticket"
2126state = "{ticket_state}"
2127priority = 0
2128effort = 5
2129risk = 3
2130author = "test"
2131owner = "test"
2132branch = "ticket/{ticket_id}-test"
2133created_at = "2026-01-01T00:00:00Z"
2134updated_at = "2026-01-01T00:00:00Z"
2135+++
2136
2137## Spec
2138
2139### Problem
2140
2141Original problem.
2142
2143### Acceptance criteria
2144
2145- [ ] Some criterion
2146
2147### Out of scope
2148
2149Nothing.
2150
2151### Approach
2152
2153Some approach.
2154
2155### Open questions
2156
2157### Amendment requests
2158
2159### Code review
2160
2161## History
2162
2163| When | From | To | By |
2164|------|------|----|----|
2165"#);
2166        fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
2167
2168        std::process::Command::new("git")
2169            .arg("init")
2170            .current_dir(root)
2171            .output()
2172            .unwrap();
2173        std::process::Command::new("git")
2174            .args(["config", "user.email", "test@test.com"])
2175            .current_dir(root)
2176            .output()
2177            .unwrap();
2178        std::process::Command::new("git")
2179            .args(["config", "user.name", "Test"])
2180            .current_dir(root)
2181            .output()
2182            .unwrap();
2183        // Create main branch with config files
2184        std::process::Command::new("git")
2185            .args(["add", ".apm"])
2186            .current_dir(root)
2187            .output()
2188            .unwrap();
2189        std::process::Command::new("git")
2190            .args(["commit", "-m", "initial commit", "--allow-empty"])
2191            .current_dir(root)
2192            .output()
2193            .unwrap();
2194        // Create the ticket branch and commit the ticket there
2195        let branch_name = format!("ticket/{ticket_id}-test");
2196        std::process::Command::new("git")
2197            .args(["checkout", "-b", &branch_name])
2198            .current_dir(root)
2199            .output()
2200            .unwrap();
2201        std::process::Command::new("git")
2202            .args(["add", &format!("tickets/{ticket_id}-test.md")])
2203            .current_dir(root)
2204            .output()
2205            .unwrap();
2206        std::process::Command::new("git")
2207            .args(["commit", "-m", &format!("ticket({ticket_id}): created")])
2208            .current_dir(root)
2209            .output()
2210            .unwrap();
2211        // Switch back to main
2212        std::process::Command::new("git")
2213            .args(["checkout", "main"])
2214            .current_dir(root)
2215            .output()
2216            .unwrap();
2217    }
2218
2219    fn make_wrapper_ctx_for_mock(
2220        project_root: &std::path::Path,
2221        ticket_id: &str,
2222        ticket_state: &str,
2223        apm_bin: &str,
2224        log_path: std::path::PathBuf,
2225    ) -> crate::wrapper::WrapperContext {
2226        let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap();
2227        let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap();
2228        let mut options = HashMap::new();
2229        options.insert("apm_bin".to_string(), apm_bin.to_string());
2230        crate::wrapper::WrapperContext {
2231            worker_name: "test-worker".to_string(),
2232            agent_type: "test".to_string(),
2233            ticket_id: ticket_id.to_string(),
2234            ticket_branch: format!("ticket/{ticket_id}-test"),
2235            worktree_path: project_root.to_path_buf(),
2236            system_prompt_file: sys_file,
2237            user_message_file: msg_file,
2238            skip_permissions: false,
2239            profile: "default".to_string(),
2240            role_prefix: None,
2241            options,
2242            model: None,
2243            log_path,
2244            container: None,
2245            extra_env: HashMap::new(),
2246            root: project_root.to_path_buf(),
2247            keychain: HashMap::new(),
2248            current_state: ticket_state.to_string(),
2249            command: None,
2250        }
2251    }
2252
2253    #[test]
2254    fn mock_happy_spec_mode_transitions_to_specd() {
2255        let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2256        let dir = tempfile::tempdir().unwrap();
2257        let root = dir.path();
2258        make_mock_project(root, "in_design", "aaaa0001");
2259        let log_path = root.join("test-worker.log");
2260        let ctx = make_wrapper_ctx_for_mock(root, "aaaa0001", "in_design", &apm_bin, log_path.clone());
2261        let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2262        let mut child = wrapper.spawn(&ctx).unwrap();
2263        child.wait().unwrap();
2264
2265        let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2266        // Read ticket from the ticket branch (where apm commits changes)
2267        let ticket_from_branch = {
2268            let out = std::process::Command::new("git")
2269                .args(["show", "ticket/aaaa0001-test:tickets/aaaa0001-test.md"])
2270                .current_dir(root)
2271                .output()
2272                .unwrap();
2273            String::from_utf8_lossy(&out.stdout).to_string()
2274        };
2275        assert!(ticket_from_branch.contains("state = \"specd\""),
2276            "ticket should be in specd state\nticket_from_branch: {ticket_from_branch}\nlog: {log_content}");
2277        assert!(ticket_from_branch.contains("### Problem"),
2278            "ticket should have Problem section\n{ticket_from_branch}");
2279        assert!(ticket_from_branch.contains("effort = 1"),
2280            "effort should be 1\n{ticket_from_branch}");
2281        assert!(ticket_from_branch.contains("risk = 1"),
2282            "risk should be 1\n{ticket_from_branch}");
2283    }
2284
2285    #[test]
2286    fn mock_happy_zero_success_transitions_returns_err() {
2287        use std::fs;
2288        let dir = tempfile::tempdir().unwrap();
2289        let root = dir.path();
2290
2291        fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
2292        fs::create_dir_all(root.join("tickets")).unwrap();
2293        fs::write(root.join(".apm/config.toml"), r#"
2294[project]
2295name = "test"
2296default_branch = "main"
2297[workers]
2298default = "mock-happy/coder"
2299[tickets]
2300dir = "tickets"
2301"#).unwrap();
2302        fs::write(root.join(".apm/workflow.toml"), r#"
2303[[workflow.states]]
2304id = "in_design"
2305label = "In Design"
2306
2307[[workflow.states]]
2308id = "closed"
2309label = "Closed"
2310terminal = true
2311"#).unwrap();
2312        fs::write(root.join(".apm/apm.worker.md"), "instructions").unwrap();
2313        fs::write(root.join(".apm/apm.spec-writer.md"), "instructions").unwrap();
2314        let ticket_content = r#"+++
2315id = "aaaa0002"
2316title = "Test"
2317state = "in_design"
2318priority = 0
2319effort = 5
2320risk = 3
2321author = "test"
2322owner = "test"
2323branch = "ticket/aaaa0002-test"
2324created_at = "2026-01-01T00:00:00Z"
2325updated_at = "2026-01-01T00:00:00Z"
2326+++
2327
2328## Spec
2329
2330### Problem
2331
2332### Acceptance criteria
2333
2334### Out of scope
2335
2336### Approach
2337
2338## History
2339
2340| When | From | To | By |
2341|------|------|----|----|
2342"#;
2343        fs::write(root.join("tickets/aaaa0002-test.md"), ticket_content).unwrap();
2344        std::process::Command::new("git").args(["init"]).current_dir(root).output().unwrap();
2345        std::process::Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap();
2346        std::process::Command::new("git").args(["config", "user.name", "T"]).current_dir(root).output().unwrap();
2347        std::process::Command::new("git").args(["add", "."]).current_dir(root).output().unwrap();
2348        std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(root).output().unwrap();
2349
2350        let log_path = root.join("test.log");
2351        let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2352        let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2353        let ctx = crate::wrapper::WrapperContext {
2354            worker_name: "test".to_string(),
2355            agent_type: "test".to_string(),
2356            ticket_id: "aaaa0002".to_string(),
2357            ticket_branch: "ticket/aaaa0002-test".to_string(),
2358            worktree_path: root.to_path_buf(),
2359            system_prompt_file: sys_file,
2360            user_message_file: msg_file,
2361            skip_permissions: false,
2362            profile: "default".to_string(),
2363            role_prefix: None,
2364            options: HashMap::new(),
2365            model: None,
2366            log_path,
2367            container: None,
2368            extra_env: HashMap::new(),
2369            root: root.to_path_buf(),
2370            keychain: HashMap::new(),
2371            current_state: "in_design".to_string(),
2372            command: None,
2373        };
2374        let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap();
2375        let result = wrapper.spawn(&ctx);
2376        assert!(result.is_err(), "mock-happy should return Err when no success transitions");
2377        let msg = result.unwrap_err().to_string();
2378        assert!(msg.contains("no success-outcome transition"), "error should mention no success transition: {msg}");
2379    }
2380
2381    #[test]
2382    fn mock_sad_transitions_to_non_success_state() {
2383        let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2384        let dir = tempfile::tempdir().unwrap();
2385        let root = dir.path();
2386        make_mock_project(root, "in_design", "aaaa0003");
2387        let log_path = root.join("test.log");
2388        let ctx = make_wrapper_ctx_for_mock(root, "aaaa0003", "in_design", &apm_bin, log_path.clone());
2389        let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2390        let mut child = wrapper.spawn(&ctx).unwrap();
2391        child.wait().unwrap();
2392
2393        let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2394        let out = std::process::Command::new("git")
2395            .args(["show", "ticket/aaaa0003-test:tickets/aaaa0003-test.md"])
2396            .current_dir(root)
2397            .output()
2398            .unwrap();
2399        let ticket_from_branch = String::from_utf8_lossy(&out.stdout).to_string();
2400        assert!(!ticket_from_branch.contains("state = \"specd\""),
2401            "mock-sad should NOT transition to specd\n{ticket_from_branch}\nlog: {log_content}");
2402        // Should have transitioned to a non-success state (or stayed in_design if the transition failed cleanly)
2403        assert!(ticket_from_branch.contains("state = \"question\"") || ticket_from_branch.contains("state = \"in_design\""),
2404            "mock-sad should transition to a non-success state\n{ticket_from_branch}\nlog: {log_content}");
2405    }
2406
2407    #[test]
2408    fn mock_sad_seed_reproducibility() {
2409        let apm_bin = match find_apm_bin() { Some(b) => b, None => return };
2410
2411        let run_mock_sad = |ticket_id: &str, seed: &str| -> String {
2412            let dir = tempfile::tempdir().unwrap();
2413            let root = dir.path();
2414            make_mock_project(root, "in_design", ticket_id);
2415            let log_path = root.join("test.log");
2416            let mut options = HashMap::new();
2417            options.insert("apm_bin".to_string(), apm_bin.clone());
2418            options.insert("seed".to_string(), seed.to_string());
2419            let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap();
2420            let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap();
2421            let ctx = crate::wrapper::WrapperContext {
2422                worker_name: "test".to_string(),
2423                agent_type: "test".to_string(),
2424                ticket_id: ticket_id.to_string(),
2425                ticket_branch: format!("ticket/{ticket_id}-test"),
2426                worktree_path: root.to_path_buf(),
2427                system_prompt_file: sys_file,
2428                user_message_file: msg_file,
2429                skip_permissions: false,
2430                profile: "default".to_string(),
2431                role_prefix: None,
2432                options,
2433                model: None,
2434                log_path,
2435                container: None,
2436                extra_env: HashMap::new(),
2437                root: root.to_path_buf(),
2438                keychain: HashMap::new(),
2439                current_state: "in_design".to_string(),
2440            command: None,
2441            };
2442            let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap();
2443            let mut child = wrapper.spawn(&ctx).unwrap();
2444            child.wait().unwrap();
2445
2446            // Read state from ticket branch (where apm commits changes)
2447            let git_content = {
2448                let o = std::process::Command::new("git")
2449                    .args(["show", &format!("ticket/{ticket_id}-test:tickets/{ticket_id}-test.md")])
2450                    .current_dir(root)
2451                    .output()
2452                    .unwrap();
2453                String::from_utf8_lossy(&o.stdout).to_string()
2454            };
2455            for line in git_content.lines() {
2456                if line.starts_with("state = ") {
2457                    return line.to_string();
2458                }
2459            }
2460            "unknown".to_string()
2461        };
2462
2463        let state1 = run_mock_sad("aaaa000a", "42");
2464        let state2 = run_mock_sad("aaaa000b", "42");
2465        assert_eq!(state1, state2, "mock-sad with same seed should pick same target state");
2466    }
2467
2468    #[test]
2469    fn debug_does_not_change_state() {
2470        let dir = tempfile::tempdir().unwrap();
2471        let root = dir.path();
2472        make_mock_project(root, "in_design", "aaaa0005");
2473        let log_path = root.join("test.log");
2474        let sys_file = crate::wrapper::write_temp_file("sys", "debug-system-prompt-unique-text").unwrap();
2475        let msg_file = crate::wrapper::write_temp_file("msg", "debug-message").unwrap();
2476        let ctx = crate::wrapper::WrapperContext {
2477            worker_name: "test-worker".to_string(),
2478            agent_type: "test".to_string(),
2479            ticket_id: "aaaa0005".to_string(),
2480            ticket_branch: "ticket/aaaa0005-test".to_string(),
2481            worktree_path: root.to_path_buf(),
2482            system_prompt_file: sys_file,
2483            user_message_file: msg_file,
2484            skip_permissions: false,
2485            profile: "default".to_string(),
2486            role_prefix: None,
2487            options: HashMap::new(),
2488            model: None,
2489            log_path: log_path.clone(),
2490            container: None,
2491            extra_env: HashMap::new(),
2492            root: root.to_path_buf(),
2493            keychain: HashMap::new(),
2494            current_state: "in_design".to_string(),
2495            command: None,
2496        };
2497        let wrapper = crate::wrapper::resolve_builtin("debug").unwrap();
2498        let mut child = wrapper.spawn(&ctx).unwrap();
2499        child.wait().unwrap();
2500
2501        // State should still be in_design (debug doesn't commit or transition)
2502        // Read from the ticket branch (HEAD of main won't have the ticket)
2503        let git_content = {
2504            let o = std::process::Command::new("git")
2505                .args(["show", "ticket/aaaa0005-test:tickets/aaaa0005-test.md"])
2506                .current_dir(root)
2507                .output()
2508                .unwrap();
2509            String::from_utf8_lossy(&o.stdout).to_string()
2510        };
2511        assert!(git_content.contains("state = \"in_design\""),
2512            "debug should not change ticket state\n{git_content}");
2513
2514        // Log file should contain APM env vars and system prompt text
2515        let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
2516        assert!(log_content.contains("APM_TICKET_ID"),
2517            "log should contain APM_TICKET_ID\n{log_content}");
2518        assert!(log_content.contains("debug-system-prompt-unique-text"),
2519            "log should contain system prompt text\n{log_content}");
2520        assert!(log_content.contains("\"type\":\"tool_use\""),
2521            "log should contain tool_use JSONL\n{log_content}");
2522    }
2523
2524    // --- resolve_dispatch_profile unit tests ---
2525
2526    fn make_minimal_config(
2527        dest_state_id: &str,
2528        dest_state_worker_profile: Option<&str>,
2529        workers_default: Option<&str>,
2530    ) -> crate::config::Config {
2531        let wp_line = dest_state_worker_profile
2532            .map(|wp| format!("worker_profile = \"{wp}\"\n"))
2533            .unwrap_or_default();
2534        let workers_section = workers_default
2535            .map(|d| format!("[workers]\ndefault = \"{d}\"\n"))
2536            .unwrap_or_default();
2537        let toml_str = format!(
2538            "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n{workers_section}\n\
2539             [[workflow.states]]\nid = \"src\"\nlabel = \"Src\"\n\n\
2540             [[workflow.states]]\nid = \"{dest_state_id}\"\nlabel = \"Dest\"\n{wp_line}"
2541        );
2542        toml::from_str(&toml_str).unwrap()
2543    }
2544
2545    #[test]
2546    fn dispatch_profile_state_wins_over_workers_default() {
2547        let config = make_minimal_config("dest", Some("claude/coder"), Some("claude/other"));
2548        let (profile, source) = super::resolve_dispatch_profile("dest", &config);
2549        assert_eq!(profile, "claude/coder", "state-level must win over workers.default");
2550        assert!(source.contains("state"), "source must mention 'state', got: {source}");
2551    }
2552
2553    #[test]
2554    fn dispatch_ignores_transition_worker_profile() {
2555        // No state profile, workers.default is set → must return workers.default (no transition fallback)
2556        let config = make_minimal_config("dest", None, Some("claude/custom"));
2557        let (profile, source) = super::resolve_dispatch_profile("dest", &config);
2558        assert_eq!(profile, "claude/custom", "must fall through to workers.default");
2559        assert_eq!(source, "workers.default");
2560    }
2561
2562    #[test]
2563    fn dispatch_profile_workers_default_fallback() {
2564        let config = make_minimal_config("dest", None, Some("claude/custom"));
2565        let (profile, source) = super::resolve_dispatch_profile("dest", &config);
2566        assert_eq!(profile, "claude/custom");
2567        assert_eq!(source, "workers.default");
2568    }
2569
2570    #[test]
2571    fn dispatch_profile_empty_when_no_workers_default() {
2572        let config = make_minimal_config("dest", None, None);
2573        let (profile, source) = super::resolve_dispatch_profile("dest", &config);
2574        assert_eq!(profile, "", "no state profile and no workers.default yields empty string");
2575        assert_eq!(source, "workers.default");
2576    }
2577
2578    // --- resolve_for_diagnostic with state-level profiles ---
2579
2580    fn make_diagnostic_repo_with_dest_profile(
2581        root: &std::path::Path,
2582        ticket_state: &str,
2583        ticket_id: &str,
2584        workers_default: Option<&str>,
2585        dest_state_worker_profile: Option<&str>,
2586    ) {
2587        use std::fs;
2588
2589        fs::create_dir_all(root.join(".apm/agents/claude")).unwrap();
2590        fs::create_dir_all(root.join("tickets")).unwrap();
2591
2592        let workers_section = workers_default
2593            .map(|d| format!("[workers]\ndefault = \"{d}\"\n"))
2594            .unwrap_or_default();
2595        fs::write(root.join(".apm/config.toml"), format!(
2596            "[project]\nname = \"test\"\ndefault_branch = \"main\"\n\n[tickets]\ndir = \"tickets\"\n\n{workers_section}"
2597        )).unwrap();
2598
2599        let dest_wp_line = if let Some(dwp) = dest_state_worker_profile {
2600            format!("worker_profile = \"{dwp}\"\n")
2601        } else {
2602            String::new()
2603        };
2604
2605        fs::write(root.join(".apm/workflow.toml"), format!(
2606            "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\n\n  [[workflow.states.transitions]]\n  to = \"in_progress\"\n  trigger = \"command:start\"\n\n\
2607             [[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\
2608             [[workflow.states]]\nid = \"done\"\nlabel = \"Done\"\nterminal = true\n\n\
2609             [[workflow.states]]\nid = \"new\"\nlabel = \"New\"\n\n  [[workflow.states.transitions]]\n  to = \"ready\"\n  trigger = \"manual\"\n  outcome = \"success\"\n"
2610        )).unwrap();
2611
2612        let ticket_content = format!(
2613            "+++\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|------|------|----|----|"
2614        );
2615        fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
2616
2617        std::process::Command::new("git").arg("init").current_dir(root).output().unwrap();
2618        std::process::Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap();
2619        std::process::Command::new("git").args(["config", "user.name", "T"]).current_dir(root).output().unwrap();
2620        std::process::Command::new("git").args(["add", ".apm"]).current_dir(root).output().unwrap();
2621        std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(root).output().unwrap();
2622        let branch = format!("ticket/{ticket_id}-test");
2623        std::process::Command::new("git").args(["checkout", "-b", &branch]).current_dir(root).output().unwrap();
2624        std::process::Command::new("git").args(["add", &format!("tickets/{ticket_id}-test.md")]).current_dir(root).output().unwrap();
2625        std::process::Command::new("git").args(["commit", "-m", "add ticket"]).current_dir(root).output().unwrap();
2626        std::process::Command::new("git").args(["checkout", "main"]).current_dir(root).output().unwrap();
2627    }
2628
2629    #[test]
2630    fn resolve_for_diagnostic_state_worker_profile_wins() {
2631        let dir = tempfile::tempdir().unwrap();
2632        let root = dir.path();
2633        // in_progress has state-level "claude/coder"; profile_source must mention "state"
2634        make_diagnostic_repo_with_dest_profile(
2635            root, "ready", "bb000001",
2636            None,
2637            Some("claude/coder"),
2638        );
2639        let diag = super::resolve_for_diagnostic(root, "bb000001").unwrap();
2640        assert_eq!(diag.worker_profile_str, "claude/coder",
2641            "state-level profile must be used");
2642        assert!(diag.profile_source.contains("state"),
2643            "profile_source must contain 'state' when from state-level; got: {}", diag.profile_source);
2644    }
2645
2646    #[test]
2647    fn resolve_for_diagnostic_workers_default_fallback() {
2648        let dir = tempfile::tempdir().unwrap();
2649        let root = dir.path();
2650        // in_progress has no state-level profile; workers.default set → must use workers.default
2651        make_diagnostic_repo_with_dest_profile(
2652            root, "ready", "bb000002",
2653            Some("claude/custom"),
2654            None,
2655        );
2656        let diag = super::resolve_for_diagnostic(root, "bb000002").unwrap();
2657        assert_eq!(diag.worker_profile_str, "claude/custom",
2658            "must fall through to workers.default; got: {}", diag.worker_profile_str);
2659        assert_eq!(diag.profile_source, "workers.default",
2660            "profile_source must be workers.default; got: {}", diag.profile_source);
2661    }
2662}