apm_core/wrapper/builtin/
mod.rs1pub mod claude;
2pub mod mock_happy;
3pub mod mock_sad;
4pub mod mock_random;
5pub mod debug;
6
7pub use claude::ClaudeWrapper;
8pub use mock_happy::MockHappyWrapper;
9pub use mock_sad::MockSadWrapper;
10pub use mock_random::MockRandomWrapper;
11pub use debug::DebugWrapper;
12
13use std::collections::HashMap;
14use crate::config::{Config, TransitionConfig, StateConfig};
15use crate::wrapper::WrapperContext;
16
17pub(crate) fn load_transitions_with_outcomes(
18 ctx: &WrapperContext,
19) -> anyhow::Result<Vec<(TransitionConfig, StateConfig)>> {
20 let config = Config::load(&ctx.root)?;
21 let current = config.workflow.states.iter()
22 .find(|s| s.id == ctx.current_state)
23 .ok_or_else(|| anyhow::anyhow!("state '{}' not found in workflow", ctx.current_state))?;
24 let state_map: HashMap<&str, &StateConfig> = config.workflow.states.iter()
25 .map(|s| (s.id.as_str(), s))
26 .collect();
27 let mut result = Vec::new();
28 for t in ¤t.transitions {
29 if let Some(&target) = state_map.get(t.to.as_str()) {
30 result.push((t.clone(), target.clone()));
31 }
32 }
33 Ok(result)
34}
35
36pub(crate) fn is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool {
37 use crate::config::CompletionStrategy;
38 transitions.iter().any(|(t, _)| t.completion != CompletionStrategy::None)
39}
40
41pub(crate) fn happy_script(id: &str, target: &str, impl_mode: bool) -> String {
42 if impl_mode {
43 format!(
44 r#"#!/bin/sh
45set -e
46APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
47ID="{id}"
48printf 'mock: placeholder implementation for ticket %s\n' "$ID" > mock-implementation.txt
49git add mock-implementation.txt
50git commit -m "mock: placeholder commit for ticket $ID"
51printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"git_commit","input":{{}}}}'
52printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}'
53"$APM" state "$ID" {target}
54rm -f "$0"
55"#
56 )
57 } else {
58 format!(
59 r#"#!/bin/sh
60set -e
61APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
62ID="{id}"
63"$APM" spec "$ID" --section "Problem" --set "Mock spec — no real problem analyzed."
64printf '%s\n' "- [ ] Mock criterion 1" "- [ ] Mock criterion 2" > ".apm-mock-ac-$$.txt"
65"$APM" spec "$ID" --section "Acceptance criteria" --set-file ".apm-mock-ac-$$.txt"
66rm -f ".apm-mock-ac-$$.txt"
67"$APM" spec "$ID" --section "Out of scope" --set "Nothing in scope for this mock run"
68"$APM" spec "$ID" --section "Approach" --set "Mock approach — no real implementation analyzed."
69"$APM" set "$ID" effort 1
70"$APM" set "$ID" risk 1
71printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_spec","input":{{}}}}'
72printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}'
73"$APM" state "$ID" {target}
74rm -f "$0"
75"#
76 )
77 }
78}
79
80pub(crate) fn sad_script(id: &str, target: &str) -> String {
81 format!(
82 r#"#!/bin/sh
83set -e
84APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
85ID="{id}"
86"$APM" spec "$ID" --section "Problem" --set "Mock sad run — spec intentionally incomplete."
87printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{{}}}}'
88"$APM" state "$ID" {target}
89rm -f "$0"
90"#
91 )
92}
93
94pub(crate) fn seed_from_ctx(ctx: &WrapperContext) -> u64 {
95 if let Some(s) = ctx.options.get("seed").and_then(|s| s.parse().ok()) {
97 return s;
98 }
99 if let Some(s) = std::env::var("APM_OPT_SEED").ok().and_then(|s| s.parse().ok()) {
101 return s;
102 }
103 use std::time::{SystemTime, UNIX_EPOCH};
105 SystemTime::now()
106 .duration_since(UNIX_EPOCH)
107 .unwrap_or_default()
108 .subsec_nanos() as u64
109}
110
111pub(crate) fn write_and_spawn_script(
112 name: &str,
113 script: &str,
114 ctx: &WrapperContext,
115) -> anyhow::Result<std::process::Child> {
116 use std::os::unix::fs::PermissionsExt;
117 use std::os::unix::process::CommandExt;
118 use crate::wrapper::CONTRACT_VERSION;
119
120 let script_path = ctx.worktree_path.join(format!(".apm-mock-{name}-{:04x}.sh", super::rand_u16()));
122 std::fs::write(&script_path, script)?;
123 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
124
125 let apm_bin = ctx.options.get("apm_bin")
127 .cloned()
128 .unwrap_or_else(|| super::resolve_apm_cli_bin());
129
130 let mut cmd = std::process::Command::new("/bin/sh");
131 cmd.arg(&script_path);
132
133 cmd.env("APM_AGENT_NAME", &ctx.worker_name);
135 cmd.env("APM_TICKET_ID", &ctx.ticket_id);
136 cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
137 cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
138 cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
139 cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
140 cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
141 cmd.env("APM_PROFILE", &ctx.profile);
142 if let Some(ref prefix) = ctx.role_prefix {
143 cmd.env("APM_ROLE_PREFIX", prefix);
144 }
145 cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
146 cmd.env("APM_BIN", &apm_bin);
147 cmd.env("APM_PROJECT_ROOT", ctx.root.to_string_lossy().as_ref());
148
149 for (k, v) in &ctx.options {
151 let env_key = format!(
152 "APM_OPT_{}",
153 k.to_uppercase().replace('.', "_").replace('-', "_")
154 );
155 cmd.env(&env_key, v);
156 }
157
158 cmd.current_dir(&ctx.worktree_path);
159 cmd.process_group(0);
160
161 let log_file = std::fs::File::create(&ctx.log_path)?;
162 let log_clone = log_file.try_clone()?;
163 cmd.stdout(log_file);
164 cmd.stderr(log_clone);
165
166 Ok(cmd.spawn()?)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::collections::HashMap;
173 use std::path::PathBuf;
174
175 fn make_ctx_with_options(opts: HashMap<String, String>) -> WrapperContext {
176 WrapperContext {
177 worker_name: "test".into(),
178 ticket_id: "t".into(),
179 ticket_branch: "b".into(),
180 worktree_path: PathBuf::from("/tmp"),
181 system_prompt_file: PathBuf::from("/tmp/sys"),
182 user_message_file: PathBuf::from("/tmp/msg"),
183 skip_permissions: false,
184 profile: "default".into(),
185 role_prefix: None,
186 options: opts,
187 model: None,
188 log_path: PathBuf::from("/tmp/log"),
189 container: None,
190 extra_env: HashMap::new(),
191 root: PathBuf::from("/tmp"),
192 keychain: HashMap::new(),
193 current_state: "test".into(),
194 command: None,
195 }
196 }
197
198 #[test]
199 fn seed_from_ctx_uses_explicit_option() {
200 let mut opts = HashMap::new();
201 opts.insert("seed".into(), "12345".into());
202 let ctx = make_ctx_with_options(opts);
203 assert_eq!(seed_from_ctx(&ctx), 12345);
204 }
205
206 #[test]
207 fn seed_from_ctx_falls_back_when_no_option() {
208 let ctx = make_ctx_with_options(HashMap::new());
211 let _ = seed_from_ctx(&ctx);
212 }
213
214 #[test]
215 fn happy_script_includes_target_state_and_id() {
216 let s = happy_script("abc123", "implemented", true);
217 assert!(s.contains("ID=\"abc123\""), "id must appear: {s}");
218 assert!(s.contains("apm\" state \"$ID\" implemented") || s.contains("$APM\" state \"$ID\" implemented"),
219 "target transition must appear: {s}");
220 }
221
222 #[test]
223 fn happy_script_spec_mode_writes_spec_sections() {
224 let s = happy_script("abc123", "specd", false);
225 assert!(s.contains("--section \"Problem\""), "spec mode must populate Problem: {s}");
226 assert!(s.contains("--section \"Acceptance criteria\""), "spec mode must populate AC: {s}");
227 }
228
229 #[test]
230 fn happy_script_impl_mode_creates_commit() {
231 let s = happy_script("abc123", "implemented", true);
232 assert!(s.contains("git commit"), "impl mode must create commit: {s}");
233 }
234
235 #[test]
236 fn sad_script_includes_target_state() {
237 let s = sad_script("abc123", "blocked");
238 assert!(s.contains("ID=\"abc123\""), "id must appear: {s}");
239 assert!(s.contains("apm\" state \"$ID\" blocked") || s.contains("$APM\" state \"$ID\" blocked"),
240 "sad target must appear: {s}");
241 }
242
243 fn make_transition(to: &str, completion: crate::config::CompletionStrategy) -> crate::config::TransitionConfig {
244 crate::config::TransitionConfig {
245 to: to.into(),
246 trigger: "command:state".into(),
247 label: String::new(),
248 hint: String::new(),
249 completion,
250 focus_section: None,
251 context_section: None,
252 warning: None,
253 on_failure: None,
254 outcome: None,
255 profile: None,
256 instructions: None,
257 role_prefix: None,
258 agent: None,
259 }
260 }
261
262 fn make_state(id: &str) -> crate::config::StateConfig {
263 crate::config::StateConfig {
264 id: id.into(),
265 label: id.into(),
266 description: String::new(),
267 actionable: vec![],
268 terminal: false,
269 worker_end: false,
270 satisfies_deps: crate::config::SatisfiesDeps::Bool(false),
271 dep_requires: None,
272 transitions: vec![],
273 instructions: None,
274 }
275 }
276
277 #[test]
278 fn is_impl_mode_true_when_any_completion_strategy() {
279 use crate::config::CompletionStrategy;
280 assert!(is_impl_mode(&[(make_transition("implemented", CompletionStrategy::Merge), make_state("implemented"))]));
281 }
282
283 #[test]
284 fn is_impl_mode_false_when_all_none() {
285 use crate::config::CompletionStrategy;
286 assert!(!is_impl_mode(&[(make_transition("specd", CompletionStrategy::None), make_state("specd"))]));
287 }
288}