use anyhow::Result;
use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
use crate::config::Config;
use crate::start::{build_system_prompt, build_user_message, explain_system_prompt, PromptProvenance, resolve_worker_profile, apply_frontmatter_agent};
use crate::ticket;
pub fn discover(root: &Path, out: &mut dyn Write) -> Result<()> {
let agents_dir = root.join(".apm/agents");
let mut agents: Vec<String> = Vec::new();
let mut roles: HashSet<String> = HashSet::new();
if agents_dir.is_dir() {
let mut dir_names: Vec<String> = std::fs::read_dir(&agents_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
dir_names.sort();
for agent_name in &dir_names {
let agent_dir = agents_dir.join(agent_name);
if let Ok(entries) = std::fs::read_dir(&agent_dir) {
for entry in entries.filter_map(|e| e.ok()) {
if let Ok(name) = entry.file_name().into_string() {
if let Some(rest) = name.strip_prefix("apm.") {
if let Some(role) = rest.strip_suffix(".md") {
roles.insert(role.to_string());
}
}
}
}
}
}
agents = dir_names;
}
let mut sorted_roles: Vec<String> = roles.into_iter().collect();
sorted_roles.sort();
let agents_str = agents.join(", ");
let roles_str = sorted_roles.join(", ");
if agents_str.is_empty() {
writeln!(out, "Agents:")?;
} else {
writeln!(out, "{:<8} {}", "Agents:", agents_str)?;
}
if roles_str.is_empty() {
writeln!(out, "Roles:")?;
} else {
writeln!(out, "{:<8} {}", "Roles:", roles_str)?;
}
Ok(())
}
fn resolve_agent_role(
config: &Config,
triggering_transition: Option<&crate::config::TransitionConfig>,
frontmatter: &crate::ticket_fmt::Frontmatter,
agent_override: Option<&str>,
role_override: Option<&str>,
) -> Result<(String, String)> {
let default_wp = config.workers.default.as_deref().unwrap_or("claude/coder");
let wp_str = triggering_transition
.and_then(|tr| tr.worker_profile.as_deref())
.unwrap_or(default_wp);
let wp = resolve_worker_profile(wp_str, &config.workers)?;
let mut agent = wp.agent.clone();
apply_frontmatter_agent(&mut agent, frontmatter, wp_str);
let agent = agent_override.unwrap_or(&agent).to_string();
let role = role_override.unwrap_or(&wp.role).to_string();
Ok((agent, role))
}
pub fn run(
root: &Path,
id: &str,
agent_override: Option<&str>,
role_override: Option<&str>,
out: &mut dyn Write,
) -> Result<()> {
let config = Config::load(root)?;
let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
let ticket_id = ticket::resolve_id_in_slice(&tickets, id)?;
let t = tickets.iter()
.find(|t| t.frontmatter.id == ticket_id)
.ok_or_else(|| anyhow::anyhow!("ticket {:?} not found", ticket_id))?;
let state = &t.frontmatter.state;
let triggering_transition = config.workflow.states.iter()
.find(|s| s.id == *state)
.and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
let (agent, role) = resolve_agent_role(&config, triggering_transition, &t.frontmatter, agent_override, role_override)?;
let project_file = config.agents.project.as_deref().map(Path::new);
let prompt = build_system_prompt(root, project_file, &agent, &role)?;
out.write_all(prompt.as_bytes())?;
Ok(())
}
pub fn explain(
root: &Path,
id: &str,
agent_override: Option<&str>,
role_override: Option<&str>,
out: &mut dyn Write,
) -> Result<()> {
let config = Config::load(root)?;
let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
let ticket_id = ticket::resolve_id_in_slice(&tickets, id)?;
let t = tickets.iter()
.find(|t| t.frontmatter.id == ticket_id)
.ok_or_else(|| anyhow::anyhow!("ticket {:?} not found", ticket_id))?;
let state = &t.frontmatter.state;
let triggering_transition = config.workflow.states.iter()
.find(|s| s.id == *state)
.and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
let (agent, role) = resolve_agent_role(&config, triggering_transition, &t.frontmatter, agent_override, role_override)?;
let project_file = config.agents.project.as_deref().map(Path::new);
let prov = explain_system_prompt(root, project_file, &agent, &role)?;
format_provenance(&prov, out)
}
pub fn run_without_ticket(
root: &Path,
agent: &str,
role: &str,
out: &mut dyn Write,
) -> Result<()> {
let config = Config::load(root)?;
let project_file = config.agents.project.as_deref().map(Path::new);
let prompt = build_system_prompt(root, project_file, agent, role)?;
out.write_all(prompt.as_bytes())?;
Ok(())
}
pub fn run_message(
root: &Path,
id: &str,
agent_override: Option<&str>,
role_override: Option<&str>,
out: &mut dyn Write,
) -> Result<()> {
let config = Config::load(root)?;
let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
let ticket_id = ticket::resolve_id_in_slice(&tickets, id)?;
let t = tickets.iter()
.find(|t| t.frontmatter.id == ticket_id)
.ok_or_else(|| anyhow::anyhow!("ticket {:?} not found", ticket_id))?;
let state = &t.frontmatter.state;
let triggering_transition = config.workflow.states.iter()
.find(|s| s.id == *state)
.and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
let (_, role) = resolve_agent_role(&config, triggering_transition, &t.frontmatter, agent_override, role_override)?;
let depends_on = t.frontmatter.depends_on.clone().unwrap_or_default();
let msg = build_user_message(root, t, &depends_on, &role, &config)?;
out.write_all(msg.as_bytes())?;
Ok(())
}
pub fn run_full(
root: &Path,
id: &str,
agent_override: Option<&str>,
role_override: Option<&str>,
out: &mut dyn Write,
) -> Result<()> {
let config = Config::load(root)?;
let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
let ticket_id = ticket::resolve_id_in_slice(&tickets, id)?;
let t = tickets.iter()
.find(|t| t.frontmatter.id == ticket_id)
.ok_or_else(|| anyhow::anyhow!("ticket {:?} not found", ticket_id))?;
let state = &t.frontmatter.state;
let triggering_transition = config.workflow.states.iter()
.find(|s| s.id == *state)
.and_then(|s| s.transitions.iter().find(|tr| tr.trigger == "command:start"));
let (agent, role) = resolve_agent_role(&config, triggering_transition, &t.frontmatter, agent_override, role_override)?;
let project_file = config.agents.project.as_deref().map(Path::new);
let depends_on = t.frontmatter.depends_on.clone().unwrap_or_default();
let sys = build_system_prompt(root, project_file, &agent, &role)?;
let msg = build_user_message(root, t, &depends_on, &role, &config)?;
writeln!(out, "=== system ===")?;
out.write_all(sys.as_bytes())?;
writeln!(out, "\n=== user ===")?;
out.write_all(msg.as_bytes())?;
Ok(())
}
pub fn explain_without_ticket(
root: &Path,
agent: &str,
role: &str,
out: &mut dyn Write,
) -> Result<()> {
let config = Config::load(root)?;
let project_file = config.agents.project.as_deref().map(Path::new);
let prov = explain_system_prompt(root, project_file, agent, role)?;
format_provenance(&prov, out)
}
fn format_provenance(prov: &PromptProvenance, out: &mut dyn Write) -> Result<()> {
let role_str = prov.layer1_role.as_deref().unwrap_or("(none)");
writeln!(out, "{:<16}apm instructions (dynamic, role: {})", "layer 1:", role_str)?;
match &prov.layer2_path {
Some(path) => writeln!(out, "{:<16}{}", "layer 2:", path)?,
None => writeln!(out, "{:<16}not configured", "layer 2:")?,
}
writeln!(
out,
"{:<16}{} (level {} \u{2014} {})",
"layer 3:",
prov.winner.source,
prov.winner.level,
prov.winner.label,
)?;
let mut first = true;
for entry in &prov.skipped {
let label = if first { "skipped:" } else { "" };
writeln!(
out,
"{:<16}level {} ({} \u{2014} {})",
label,
entry.level,
entry.label,
entry.source,
)?;
first = false;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_parity_project(root: &Path, ticket_id: &str) {
use std::fs;
fs::create_dir_all(root.join(".apm/agents/mock-happy")).unwrap();
fs::create_dir_all(root.join("tickets")).unwrap();
fs::write(root.join(".apm/agents/mock-happy/apm.worker.md"), "PER-AGENT INSTRUCTIONS").unwrap();
fs::write(root.join(".apm/config.toml"), r#"
[project]
name = "parity-test"
default_branch = "main"
[workers]
default = "mock-happy/worker"
[tickets]
dir = "tickets"
"#).unwrap();
fs::write(root.join(".apm/workflow.toml"), r#"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_progress"
trigger = "command:start"
label = "Start"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
"#).unwrap();
let ticket_content = format!(r#"+++
id = "{ticket_id}"
title = "Parity Test"
state = "ready"
priority = 0
effort = 5
risk = 3
author = "test"
owner = "test"
branch = "ticket/{ticket_id}-test"
created_at = "2026-01-01T00:00:00Z"
updated_at = "2026-01-01T00:00:00Z"
+++
## Spec
### Problem
Test.
## History
| When | From | To | By |
|------|------|----|----|
"#);
fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
std::process::Command::new("git")
.arg("init").current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "T"]).current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["add", ".apm"]).current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init", "--allow-empty"]).current_dir(root).output().unwrap();
let branch = format!("ticket/{ticket_id}-test");
std::process::Command::new("git")
.args(["checkout", "-b", &branch]).current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["add", &format!("tickets/{ticket_id}-test.md")]).current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add ticket"]).current_dir(root).output().unwrap();
std::process::Command::new("git")
.args(["checkout", "main"]).current_dir(root).output().unwrap();
}
#[test]
fn parity_build_system_prompt_matches_prompt_run() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_parity_project(root, "abcd0001");
let expected = crate::start::build_system_prompt(
root,
None,
"mock-happy",
"worker",
).unwrap();
let mut buf = Vec::new();
run(root, "abcd0001", None, None, &mut buf).unwrap();
let actual = String::from_utf8(buf).unwrap();
assert_eq!(
actual, expected,
"prompt::run output must match build_system_prompt output for same inputs"
);
}
#[test]
fn discover_lists_agents_and_roles() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".apm/agents/mock-happy")).unwrap();
fs::create_dir_all(root.join(".apm/agents/pi")).unwrap();
fs::write(root.join(".apm/agents/mock-happy/apm.worker.md"), "").unwrap();
fs::write(root.join(".apm/agents/pi/apm.spec-writer.md"), "").unwrap();
let mut buf = Vec::new();
discover(root, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Agents: mock-happy, pi"), "got: {output:?}");
assert!(output.contains("Roles: spec-writer, worker"), "got: {output:?}");
}
#[test]
fn discover_no_agents_dir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut buf = Vec::new();
let result = discover(root, &mut buf);
assert!(result.is_ok());
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Agents:"), "got: {output:?}");
assert!(output.contains("Roles:"), "got: {output:?}");
}
#[test]
fn discover_deduplicates_roles() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".apm/agents/alpha")).unwrap();
fs::create_dir_all(root.join(".apm/agents/beta")).unwrap();
fs::write(root.join(".apm/agents/alpha/apm.worker.md"), "").unwrap();
fs::write(root.join(".apm/agents/beta/apm.worker.md"), "").unwrap();
let mut buf = Vec::new();
discover(root, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let roles_line = output.lines().find(|l| l.starts_with("Roles:")).unwrap();
let count = roles_line.matches("worker").count();
assert_eq!(count, 1, "role should appear once; got: {roles_line:?}");
}
fn make_explain_project(root: &Path, ticket_id: &str, agent: &str, create_per_agent_file: bool, project: Option<&str>) {
use std::fs;
fs::create_dir_all(root.join(".apm")).unwrap();
fs::create_dir_all(root.join("tickets")).unwrap();
if create_per_agent_file {
let agent_dir = root.join(format!(".apm/agents/{agent}"));
fs::create_dir_all(&agent_dir).unwrap();
fs::write(agent_dir.join("apm.coder.md"), format!("INSTRUCTIONS FOR {agent}")).unwrap();
}
let agents_section = match project {
Some(path) => format!("\n[agents]\nproject = \"{path}\"\n"),
None => String::new(),
};
fs::write(root.join(".apm/config.toml"), format!(r#"
[project]
name = "explain-test"
default_branch = "main"
[workers]
default = "{agent}/coder"
[tickets]
dir = "tickets"
{agents_section}"#)).unwrap();
fs::write(root.join(".apm/workflow.toml"), r#"
[[workflow.states]]
id = "ready"
label = "Ready"
actionable = ["agent"]
[[workflow.states.transitions]]
to = "in_progress"
trigger = "command:start"
label = "Start"
[[workflow.states]]
id = "in_progress"
label = "In Progress"
"#).unwrap();
let ticket_content = format!(r#"+++
id = "{ticket_id}"
title = "Explain Test"
state = "ready"
priority = 0
effort = 5
risk = 3
author = "test"
owner = "test"
branch = "ticket/{ticket_id}-test"
created_at = "2026-01-01T00:00:00Z"
updated_at = "2026-01-01T00:00:00Z"
+++
## Spec
### Problem
Test.
## History
| When | From | To | By |
|------|------|----|----|
"#);
fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git").args(args).current_dir(root).output().unwrap()
};
git(&["init"]);
git(&["config", "user.email", "t@t.com"]);
git(&["config", "user.name", "T"]);
git(&["add", ".apm"]);
git(&["commit", "-m", "init", "--allow-empty"]);
let branch = format!("ticket/{ticket_id}-test");
git(&["checkout", "-b", &branch]);
git(&["add", &format!("tickets/{ticket_id}-test.md")]);
git(&["commit", "-m", "add ticket"]);
git(&["checkout", "main"]);
}
#[test]
fn explain_level0_wins() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_explain_project(root, "aaaa0001", "mock-happy", true, None);
let mut buf = Vec::new();
explain(root, "aaaa0001", None, None, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("level 0"), "should show level 0; got:\n{output}");
assert!(output.contains(".apm/agents/mock-happy/apm.coder.md"), "should show per-agent path; got:\n{output}");
assert_eq!(output.matches("not reached").count(), 2, "levels 1-2 should be not reached; got:\n{output}");
}
#[test]
fn explain_level2_wins() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_explain_project(root, "bbbb0002", "claude", false, None);
let mut buf = Vec::new();
explain(root, "bbbb0002", None, None, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("level 2"), "should show level 2; got:\n{output}");
assert!(output.contains("built-in default"), "should show built-in default; got:\n{output}");
}
#[test]
fn explain_prefix_shown() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let project_path = ".apm/project.md";
std::fs::create_dir_all(root.join(".apm")).unwrap();
std::fs::write(root.join(project_path), "Project context.").unwrap();
make_explain_project(root, "cccc0003", "claude", false, Some(project_path));
let mut buf = Vec::new();
explain(root, "cccc0003", None, None, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let layer2_line = output.lines().find(|l| l.starts_with("layer 2:")).unwrap();
assert!(
layer2_line.contains(project_path),
"layer 2 line should name the configured file; got: {layer2_line:?}"
);
}
#[test]
fn explain_agent_role_override() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_explain_project(root, "dddd0004", "mock-happy", true, None);
let mut buf = Vec::new();
explain(root, "dddd0004", Some("claude"), Some("coder"), &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("level 2"), "claude override should fall to level 2; got:\n{output}");
assert!(output.contains("built-in default (claude/coder)"), "should name claude/coder; got:\n{output}");
assert!(output.contains(".apm/agents/claude/apm.coder.md"), "skipped level 0 should use overridden agent name; got:\n{output}");
}
#[test]
fn run_message_contains_ticket_id() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_parity_project(root, "abcd0005");
let mut buf = Vec::new();
run_message(root, "abcd0005", None, None, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("abcd0005"), "user message should contain ticket id; got:\n{output}");
assert!(output.contains("Worker agent"), "user message should contain role prefix; got:\n{output}");
}
#[test]
fn run_full_contains_both_sections() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_parity_project(root, "abcd0006");
let mut buf = Vec::new();
run_full(root, "abcd0006", None, None, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("=== system ==="), "full output should have system header; got:\n{output}");
assert!(output.contains("=== user ==="), "full output should have user header; got:\n{output}");
let sys_pos = output.find("=== system ===").unwrap();
let usr_pos = output.find("=== user ===").unwrap();
assert!(sys_pos < usr_pos, "system section should come before user section");
assert!(output.contains("PER-AGENT INSTRUCTIONS"), "system prompt content should appear; got:\n{output}");
assert!(output.contains("abcd0006"), "ticket id should appear in user section; got:\n{output}");
}
#[test]
fn run_full_system_matches_run() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
make_parity_project(root, "abcd0007");
let mut sys_buf = Vec::new();
run(root, "abcd0007", None, None, &mut sys_buf).unwrap();
let sys_only = String::from_utf8(sys_buf).unwrap();
let mut full_buf = Vec::new();
run_full(root, "abcd0007", None, None, &mut full_buf).unwrap();
let full = String::from_utf8(full_buf).unwrap();
let sys_section = full
.split("=== user ===")
.next()
.unwrap()
.trim_start_matches("=== system ===\n");
assert_eq!(sys_section.trim_end(), sys_only.trim_end(),
"system section in run_full must match run() output");
}
}