Skip to main content

apm_core/
start.rs

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