pub mod claude;
pub mod mock_happy;
pub mod mock_sad;
pub mod mock_random;
pub mod debug;
pub use claude::ClaudeWrapper;
pub use mock_happy::MockHappyWrapper;
pub use mock_sad::MockSadWrapper;
pub use mock_random::MockRandomWrapper;
pub use debug::DebugWrapper;
use std::collections::HashMap;
use crate::config::{Config, TransitionConfig, StateConfig};
use crate::wrapper::WrapperContext;
pub(crate) fn load_transitions_with_outcomes(
ctx: &WrapperContext,
) -> anyhow::Result<Vec<(TransitionConfig, StateConfig)>> {
let config = Config::load(&ctx.root)?;
let current = config.workflow.states.iter()
.find(|s| s.id == ctx.current_state)
.ok_or_else(|| anyhow::anyhow!("state '{}' not found in workflow", ctx.current_state))?;
let state_map: HashMap<&str, &StateConfig> = config.workflow.states.iter()
.map(|s| (s.id.as_str(), s))
.collect();
let mut result = Vec::new();
for t in ¤t.transitions {
if let Some(&target) = state_map.get(t.to.as_str()) {
result.push((t.clone(), target.clone()));
}
}
Ok(result)
}
pub(crate) fn is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool {
use crate::config::CompletionStrategy;
transitions.iter().any(|(t, _)| t.completion != CompletionStrategy::None)
}
pub(crate) fn happy_script(id: &str, target: &str, impl_mode: bool) -> String {
if impl_mode {
format!(
r#"#!/bin/sh
set -e
APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
ID="{id}"
printf 'mock: placeholder implementation for ticket %s\n' "$ID" > mock-implementation.txt
git add mock-implementation.txt
git commit -m "mock: placeholder commit for ticket $ID"
printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"git_commit","input":{{}}}}'
printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}'
"$APM" state "$ID" {target}
rm -f "$0"
"#
)
} else {
format!(
r#"#!/bin/sh
set -e
APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
ID="{id}"
"$APM" spec "$ID" --section "Problem" --set "Mock spec — no real problem analyzed."
printf '%s\n' "- [ ] Mock criterion 1" "- [ ] Mock criterion 2" > ".apm-mock-ac-$$.txt"
"$APM" spec "$ID" --section "Acceptance criteria" --set-file ".apm-mock-ac-$$.txt"
rm -f ".apm-mock-ac-$$.txt"
"$APM" spec "$ID" --section "Out of scope" --set "Nothing in scope for this mock run"
"$APM" spec "$ID" --section "Approach" --set "Mock approach — no real implementation analyzed."
"$APM" set "$ID" effort 1
"$APM" set "$ID" risk 1
printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_spec","input":{{}}}}'
printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}'
"$APM" state "$ID" {target}
rm -f "$0"
"#
)
}
}
pub(crate) fn sad_script(id: &str, target: &str) -> String {
format!(
r#"#!/bin/sh
set -e
APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
ID="{id}"
"$APM" spec "$ID" --section "Problem" --set "Mock sad run — spec intentionally incomplete."
printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{{}}}}'
"$APM" state "$ID" {target}
rm -f "$0"
"#
)
}
pub(crate) fn seed_from_ctx(ctx: &WrapperContext) -> u64 {
if let Some(s) = ctx.options.get("seed").and_then(|s| s.parse().ok()) {
return s;
}
if let Some(s) = std::env::var("APM_OPT_SEED").ok().and_then(|s| s.parse().ok()) {
return s;
}
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as u64
}
pub(crate) fn write_and_spawn_script(
name: &str,
script: &str,
ctx: &WrapperContext,
) -> anyhow::Result<std::process::Child> {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::CommandExt;
use crate::wrapper::CONTRACT_VERSION;
let script_path = ctx.worktree_path.join(format!(".apm-mock-{name}-{:04x}.sh", super::rand_u16()));
std::fs::write(&script_path, script)?;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
let apm_bin = ctx.options.get("apm_bin")
.cloned()
.unwrap_or_else(|| super::resolve_apm_cli_bin());
let mut cmd = std::process::Command::new("/bin/sh");
cmd.arg(&script_path);
cmd.env("APM_AGENT_NAME", &ctx.worker_name);
cmd.env("APM_TICKET_ID", &ctx.ticket_id);
cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
cmd.env("APM_PROFILE", &ctx.profile);
if let Some(ref prefix) = ctx.role_prefix {
cmd.env("APM_ROLE_PREFIX", prefix);
}
cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
cmd.env("APM_BIN", &apm_bin);
cmd.env("APM_PROJECT_ROOT", ctx.root.to_string_lossy().as_ref());
for (k, v) in &ctx.options {
let env_key = format!(
"APM_OPT_{}",
k.to_uppercase().replace('.', "_").replace('-', "_")
);
cmd.env(&env_key, v);
}
cmd.current_dir(&ctx.worktree_path);
cmd.process_group(0);
let log_file = std::fs::File::create(&ctx.log_path)?;
let log_clone = log_file.try_clone()?;
cmd.stdout(log_file);
cmd.stderr(log_clone);
Ok(cmd.spawn()?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
fn make_ctx_with_options(opts: HashMap<String, String>) -> WrapperContext {
WrapperContext {
worker_name: "test".into(),
ticket_id: "t".into(),
ticket_branch: "b".into(),
worktree_path: PathBuf::from("/tmp"),
system_prompt_file: PathBuf::from("/tmp/sys"),
user_message_file: PathBuf::from("/tmp/msg"),
skip_permissions: false,
profile: "default".into(),
role_prefix: None,
options: opts,
model: None,
log_path: PathBuf::from("/tmp/log"),
container: None,
extra_env: HashMap::new(),
root: PathBuf::from("/tmp"),
keychain: HashMap::new(),
current_state: "test".into(),
command: None,
}
}
#[test]
fn seed_from_ctx_uses_explicit_option() {
let mut opts = HashMap::new();
opts.insert("seed".into(), "12345".into());
let ctx = make_ctx_with_options(opts);
assert_eq!(seed_from_ctx(&ctx), 12345);
}
#[test]
fn seed_from_ctx_falls_back_when_no_option() {
let ctx = make_ctx_with_options(HashMap::new());
let _ = seed_from_ctx(&ctx);
}
#[test]
fn happy_script_includes_target_state_and_id() {
let s = happy_script("abc123", "implemented", true);
assert!(s.contains("ID=\"abc123\""), "id must appear: {s}");
assert!(s.contains("apm\" state \"$ID\" implemented") || s.contains("$APM\" state \"$ID\" implemented"),
"target transition must appear: {s}");
}
#[test]
fn happy_script_spec_mode_writes_spec_sections() {
let s = happy_script("abc123", "specd", false);
assert!(s.contains("--section \"Problem\""), "spec mode must populate Problem: {s}");
assert!(s.contains("--section \"Acceptance criteria\""), "spec mode must populate AC: {s}");
}
#[test]
fn happy_script_impl_mode_creates_commit() {
let s = happy_script("abc123", "implemented", true);
assert!(s.contains("git commit"), "impl mode must create commit: {s}");
}
#[test]
fn sad_script_includes_target_state() {
let s = sad_script("abc123", "blocked");
assert!(s.contains("ID=\"abc123\""), "id must appear: {s}");
assert!(s.contains("apm\" state \"$ID\" blocked") || s.contains("$APM\" state \"$ID\" blocked"),
"sad target must appear: {s}");
}
fn make_transition(to: &str, completion: crate::config::CompletionStrategy) -> crate::config::TransitionConfig {
crate::config::TransitionConfig {
to: to.into(),
trigger: "command:state".into(),
label: String::new(),
hint: String::new(),
completion,
focus_section: None,
context_section: None,
warning: None,
on_failure: None,
outcome: None,
profile: None,
instructions: None,
role_prefix: None,
agent: None,
}
}
fn make_state(id: &str) -> crate::config::StateConfig {
crate::config::StateConfig {
id: id.into(),
label: id.into(),
description: String::new(),
actionable: vec![],
terminal: false,
worker_end: false,
satisfies_deps: crate::config::SatisfiesDeps::Bool(false),
dep_requires: None,
transitions: vec![],
instructions: None,
}
}
#[test]
fn is_impl_mode_true_when_any_completion_strategy() {
use crate::config::CompletionStrategy;
assert!(is_impl_mode(&[(make_transition("implemented", CompletionStrategy::Merge), make_state("implemented"))]));
}
#[test]
fn is_impl_mode_false_when_all_none() {
use crate::config::CompletionStrategy;
assert!(!is_impl_mode(&[(make_transition("specd", CompletionStrategy::None), make_state("specd"))]));
}
}