Skip to main content

apm_core/
start.rs

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