1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::Path;
4
5use crate::config::Config;
6
7static STATIC_STATE_MACHINE: &str = "| From | To | Command |\n\
12|------|----|----------|\n\
13| new | groomed | apm state <id> groomed |\n\
14| new | closed | apm state <id> closed |\n\
15| groomed | in_design | apm state <id> in_design |\n\
16| groomed | closed | apm state <id> closed |\n\
17| in_design | specd | apm state <id> specd |\n\
18| in_design | question | apm state <id> question |\n\
19| specd | ready | apm state <id> ready |\n\
20| specd | ammend | apm state <id> ammend |\n\
21| specd | in_design | apm state <id> in_design |\n\
22| ammend | in_design | apm start <id> |\n\
23| ready | in_progress | apm start <id> |\n\
24| in_progress | implemented | apm state <id> implemented |\n\
25| in_progress | blocked | apm state <id> blocked |\n\
26| blocked | ready | apm state <id> ready |\n\
27| implemented | closed | apm state <id> closed |\n\
28| implemented | ready | apm state <id> ready |\n";
29
30static STATIC_TICKET_FORMAT: &str = "Standard frontmatter fields (TOML between +++ delimiters):\n\
31\n\
32Required fields:\n\
33 id — unique 8-char hex identifier\n\
34 title — short human-readable summary\n\
35 state — current workflow state (e.g. new, ready, in_progress)\n\
36 priority — integer; higher = picked first by apm next\n\
37 effort — integer 1-10; implementation scale estimate\n\
38 risk — integer 1-10; technical risk estimate\n\
39 author — username who created the ticket\n\
40 owner — username responsible for the ticket\n\
41 branch — git branch name (ticket/<id>-<slug>)\n\
42 created_at — ISO 8601 timestamp\n\
43 updated_at — ISO 8601 timestamp\n\
44\n\
45Optional fields:\n\
46 epic — parent epic ID\n\
47 target_branch — integration target (defaults to project default branch)\n\
48 depends_on — comma-separated list of blocking ticket IDs\n\
49\n\
50Body sections (under ## Spec):\n\
51\n\
52 ### Problem (free, required)\n\
53 What is broken or missing, and why it matters.\n\
54\n\
55 ### Acceptance criteria (tasks, required)\n\
56 Checkbox list; each item independently testable.\n\
57\n\
58 ### Out of scope (free, required)\n\
59 Explicit list of what this ticket does not cover.\n\
60\n\
61 ### Approach (free, required)\n\
62 How the implementation will work.\n\
63\n\
64 ### Open questions (qa)\n\
65 Blocking questions for the supervisor.\n\
66\n\
67 ### Amendment requests (tasks)\n\
68 Supervisor-requested changes to the spec.\n\
69\n\
70 ## History (auto-managed)\n\
71 Transition log written by apm. Never edit manually.\n\
72\n\
73Ticket file rules:\n\
74 - Do not hand-edit the History section — apm state appends rows automatically.\n\
75 - Do not rename the ticket file. The filename (tickets/<id>-<slug>.md) is derived\n\
76 from the branch name and is load-bearing for all apm lookups.\n\
77 - Find the exact filename with: ls tickets/<id>-*.md\n";
78
79static SESSION_IDENTITY_BODY: &str = "Generate a unique session name at the start of every session.\n\
80Use a fixed string — do not use $() substitution inline, as it triggers\n\
81permission prompts. Pick a name of the form claude-MMDD-HHMM-XXXX\n\
82(e.g. claude-0325-1430-a3f9) and export it before running any apm command:\n\
83\n\
84 export APM_AGENT_NAME=claude-0325-1430-a3f9\n\
85\n\
86Hold the same name for the entire session. Do not regenerate mid-session.\n\
87Engineers set APM_AGENT_NAME to their own username when working directly.\n";
88
89pub fn generate(root: &Path, role: Option<&str>, ticket_id: Option<&str>, commands: &[(String, String)], current_state: Option<&str>) -> Result<String> {
107 if role.is_none() {
109 return Ok(role_index_body(root));
110 }
111
112 let config = Config::load(root).ok();
113 let mut out = String::new();
114
115 out.push_str("## State Machine\n\n");
117 out.push_str(&state_machine_body(config.as_ref(), role));
118
119 if ticket_id.is_some() {
121 let body = exit_scenarios_body(config.as_ref(), current_state);
122 if !body.is_empty() {
123 out.push_str("## Exit scenarios\n\n");
124 out.push_str("Choose the matching scenario and run the commands. Replace any <placeholder> text with your own.\n\n");
125 out.push_str(&body);
126 }
127 }
128
129 out.push_str("## Ticket Format\n\n");
131 out.push_str(&ticket_format_body(config.as_ref()));
132
133 out.push_str("## Session Identity\n\n");
135 out.push_str(SESSION_IDENTITY_BODY);
136 out.push('\n');
137
138 let cr = command_reference_body(role, commands);
140 if !cr.is_empty() {
141 out.push_str("## Command Reference\n\n");
142 out.push_str(&cr);
143 }
144
145 if let Some(id) = ticket_id {
147 out = out.replace("<id>", id);
148 }
149
150 Ok(out)
151}
152
153fn state_machine_body(config: Option<&Config>, role: Option<&str>) -> String {
158 if let Some(cfg) = config {
159 if !cfg.workflow.states.is_empty() {
160 return format_live_state_machine(cfg, role);
161 }
162 }
163 STATIC_STATE_MACHINE.to_string()
164}
165
166fn format_live_state_machine(config: &Config, role: Option<&str>) -> String {
167 let mut out = String::new();
168 out.push_str("| From | To | Command |\n");
169 out.push_str("|------|----|----------|\n");
170
171 for state in &config.workflow.states {
172 let state_role: Option<&str> = state.worker_profile.as_deref()
173 .and_then(|wp| wp.split_once('/').map(|(_, r)| r));
174
175 for transition in &state.transitions {
176 if let Some(role_name) = role {
177 if state_role != Some(role_name) {
178 continue;
179 }
180 }
181 let command = if transition.trigger == "command:start" {
182 "apm start <id>".to_string()
183 } else {
184 format!("apm state <id> {}", transition.to)
185 };
186 out.push_str(&format!("| {} | {} | {} |\n", state.id, transition.to, command));
187 }
188 }
189 out.push('\n');
190 out
191}
192
193fn ticket_format_body(config: Option<&Config>) -> String {
194 if let Some(cfg) = config {
195 if !cfg.ticket.sections.is_empty() {
196 return format_live_ticket_format(cfg);
197 }
198 }
199 STATIC_TICKET_FORMAT.to_string()
200}
201
202fn format_live_ticket_format(config: &Config) -> String {
203 let mut out = String::new();
204
205 out.push_str("Standard frontmatter fields (TOML between +++ delimiters):\n\n");
206 out.push_str("Required fields:\n");
207 out.push_str(" id, title, state, priority, effort, risk, author, owner, branch,\n");
208 out.push_str(" created_at, updated_at\n\n");
209 out.push_str("Optional fields:\n");
210 out.push_str(" epic, target_branch, depends_on\n\n");
211 out.push_str("Body sections (under ## Spec):\n\n");
212
213 for section in &config.ticket.sections {
214 use crate::config::SectionType;
215 let type_label = match section.type_ {
216 SectionType::Free => "free",
217 SectionType::Tasks => "tasks",
218 SectionType::Qa => "qa",
219 };
220 let req_label = if section.required { ", required" } else { "" };
221 out.push_str(&format!(
222 " ### {} ({}{}) \n",
223 section.name, type_label, req_label
224 ));
225 if let Some(ref placeholder) = section.placeholder {
226 out.push_str(&format!(" {}\n", placeholder));
227 }
228 }
229
230 out.push_str("\n ## History (auto-managed)\n");
231 out.push_str(" Transition log written by apm. Never edit manually.\n");
232 out.push_str("\nTicket file rules:\n");
233 out.push_str(" - Do not hand-edit the History section — apm state appends rows automatically.\n");
234 out.push_str(" - Do not rename the ticket file. The filename (tickets/<id>-<slug>.md) is derived\n");
235 out.push_str(" from the branch name and is load-bearing for all apm lookups.\n");
236 out.push_str(" - Find the exact filename with: ls tickets/<id>-*.md\n");
237 out
238}
239
240fn role_index_body(root: &Path) -> String {
241 let mut out = String::from("## Available Roles\n\n");
242
243 let hardcoded: &[(&str, &str)] = &[
244 ("coder", "Implements tickets in a git worktree"),
245 ("spec-writer", "Writes and revises ticket specs"),
246 ("main-agent", "Project management companion for the supervisor"),
247 ];
248
249 let hardcoded_names: HashSet<&str> = hardcoded.iter().map(|(n, _)| *n).collect();
250 let mut extra_roles: Vec<String> = Vec::new();
251
252 let agents_dir = root.join(".apm/agents");
253 if agents_dir.is_dir() {
254 if let Ok(entries) = std::fs::read_dir(&agents_dir) {
255 for entry in entries.filter_map(|e| e.ok()) {
256 let agent_dir = entry.path();
257 if !agent_dir.is_dir() {
258 continue;
259 }
260 if let Ok(files) = std::fs::read_dir(&agent_dir) {
261 for file in files.filter_map(|e| e.ok()) {
262 if let Ok(name) = file.file_name().into_string() {
263 if let Some(rest) = name.strip_prefix("apm.") {
264 if let Some(role) = rest.strip_suffix(".md") {
265 if !hardcoded_names.contains(role)
266 && !extra_roles.iter().any(|r| r == role)
267 {
268 extra_roles.push(role.to_string());
269 }
270 }
271 }
272 }
273 }
274 }
275 }
276 }
277 }
278 extra_roles.sort();
279
280 for (name, desc) in hardcoded {
281 out.push_str(&format!(" {:<16}{}\n", name, desc));
282 }
283 for role in &extra_roles {
284 out.push_str(&format!(" {:<16}(custom role)\n", role));
285 }
286 out.push('\n');
287 out
288}
289
290fn command_reference_body(role: Option<&str>, commands: &[(String, String)]) -> String {
291 let allowlist = role.and_then(role_command_allowlist);
292
293 let filtered: Vec<&(String, String)> = if let Some(allow) = allowlist {
294 commands
295 .iter()
296 .filter(|(name, _)| allow.contains(&name.as_str()))
297 .collect()
298 } else {
299 commands.iter().collect()
300 };
301
302 if filtered.is_empty() {
303 return String::new();
304 }
305
306 let max_name = filtered.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
307 let col_width = 4 + max_name; let mut out = String::new();
310 for (name, about) in &filtered {
311 let label = format!("apm {}", name);
312 out.push_str(&format!(" {:<col_width$} {}\n", label, about));
313 }
314 out.push('\n');
315 out
316}
317
318const WORKER_COMMAND_ALLOWLIST: &[&str] = &["show", "state", "spec", "set", "new", "instructions"];
323
324pub(crate) const WORKER_COMMANDS: &[(&str, &str)] = &[
330 ("instructions", "Output APM system knowledge for agents: state machine, exit scenarios (when a ticket id is given), ticket format, session identity, and command reference"),
331 ("new", "Create a new ticket"),
332 ("set", "Set a field on a ticket"),
333 ("show", "Show a ticket"),
334 ("spec", "Read or write individual spec sections of a ticket"),
335 ("state", "Transition a ticket's state"),
336];
337
338fn role_command_allowlist(_role: &str) -> Option<&'static [&'static str]> {
339 Some(WORKER_COMMAND_ALLOWLIST)
340}
341
342fn exit_scenarios_body(config: Option<&Config>, current_state: Option<&str>) -> String {
343 let (config, current_state) = match (config, current_state) {
344 (Some(c), Some(s)) => (c, s),
345 _ => return String::new(),
346 };
347 let state_cfg = match config.workflow.states.iter().find(|s| s.id == current_state) {
348 Some(s) if s.worker_profile.is_some() => s,
349 _ => return String::new(),
350 };
351 let hinted: Vec<_> = state_cfg.transitions.iter()
352 .filter(|t| t.worker_hint.is_some())
353 .collect();
354 if hinted.is_empty() {
355 return String::new();
356 }
357 let mut out = String::new();
358 for t in hinted {
359 let hint = t.worker_hint.as_ref().unwrap();
360 out.push_str(&format!("### {}\n\n", hint));
361 if let Some(pre) = &t.worker_pre {
362 out.push_str(pre);
363 out.push('\n');
364 }
365 out.push_str(&format!("apm state <id> {}\n\n", t.to));
366 }
367 out
368}
369
370#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn empty_commands() -> Vec<(String, String)> {
379 vec![]
380 }
381
382 fn sample_commands() -> Vec<(String, String)> {
383 vec![
384 ("show".to_string(), "Show a ticket".to_string()),
385 ("start".to_string(), "Claim a ticket".to_string()),
386 ("state".to_string(), "Transition state".to_string()),
387 ("spec".to_string(), "Read or write spec sections".to_string()),
388 ("new".to_string(), "Create a new ticket".to_string()),
389 ("sync".to_string(), "Sync with remote".to_string()),
390 ("list".to_string(), "List tickets".to_string()),
391 ("next".to_string(), "Return next actionable ticket".to_string()),
392 ("set".to_string(), "Set a field on a ticket".to_string()),
393 ("prompt".to_string(), "Print system prompt".to_string()),
394 ("instructions".to_string(), "Print APM system knowledge".to_string()),
395 ]
396 }
397
398 #[test]
399 fn generate_no_role_lists_roles() {
400 let tmp = tempfile::tempdir().unwrap();
401 let out = generate(tmp.path(), None, None, &empty_commands(), None).unwrap();
402 assert!(out.contains("coder"), "coder missing from role index");
403 assert!(out.contains("spec-writer"), "spec-writer missing from role index");
404 assert!(out.contains("main-agent"), "main-agent missing from role index");
405 assert!(!out.contains("## State Machine"), "State Machine should be absent with no role");
406 }
407
408 #[test]
409 fn generate_role_table_precedes_command_reference() {
410 let tmp = tempfile::tempdir().unwrap();
411 let out = generate(tmp.path(), Some("worker"), None, &sample_commands(), None).unwrap();
413 let pos_sm = out.find("## State Machine").unwrap();
414 let pos_cr = out.find("## Command Reference").unwrap();
415 assert!(pos_sm < pos_cr, "State Machine must precede Command Reference");
416 }
417
418 #[test]
419 fn generate_no_ansi() {
420 let tmp = tempfile::tempdir().unwrap();
421 let out = generate(tmp.path(), None, None, &sample_commands(), None).unwrap();
422 assert!(!out.contains('\x1b'), "ANSI escape code found in output");
423 }
424
425 #[test]
426 fn generate_is_idempotent() {
427 let tmp = tempfile::tempdir().unwrap();
428 let commands = sample_commands();
429 let out1 = generate(tmp.path(), Some("worker"), None, &commands, None).unwrap();
430 let out2 = generate(tmp.path(), Some("worker"), None, &commands, None).unwrap();
431 assert_eq!(out1, out2, "generate is not idempotent");
432 }
433
434 #[test]
435 fn generate_role_independent_sections() {
436 let tmp = tempfile::tempdir().unwrap();
437 let out = generate(tmp.path(), Some("worker"), None, &sample_commands(), None).unwrap();
438 assert!(out.contains("## Session Identity"), "Session Identity missing with role");
439 assert!(out.contains("APM_AGENT_NAME"), "APM_AGENT_NAME identity missing");
440 assert!(out.contains("| From | To | Command |"), "table header missing");
442 }
443
444 #[test]
445 fn shell_discipline_absent_from_instructions() {
446 let tmp = tempfile::tempdir().unwrap();
447 let out = generate(tmp.path(), None, None, &empty_commands(), None).unwrap();
448 assert!(!out.contains("## Shell Discipline"), "Shell Discipline must not appear in apm instructions");
449 assert!(!out.contains("Do not batch tool calls in parallel"), "parallel batching rule must not appear in apm instructions");
450 }
451
452 #[test]
453 fn generate_worker_scopes_commands() {
454 let tmp = tempfile::tempdir().unwrap();
455 let out = generate(tmp.path(), Some("worker"), None, &sample_commands(), None).unwrap();
456
457 let cr_pos = out.find("## Command Reference").unwrap();
458 let cr_section = &out[cr_pos..];
459
460 assert!(cr_section.contains("apm show"), "'apm show' missing for worker role");
462 assert!(cr_section.contains("apm state"), "'apm state' missing for worker role");
463 assert!(cr_section.contains("apm spec"), "'apm spec' missing for worker role");
464 assert!(cr_section.contains("apm set"), "'apm set' missing for worker role");
465 assert!(cr_section.contains("apm new"), "'apm new' missing for worker role");
466 assert!(cr_section.contains("apm instructions"), "'apm instructions' missing for worker role");
467
468 assert!(!cr_section.contains("apm start"), "'apm start' found in worker command reference but should be excluded");
470 assert!(!cr_section.contains("apm sync"), "'apm sync' found in worker command reference but should be excluded");
471 assert!(!cr_section.contains("apm prompt"), "'apm prompt' found in worker command reference but should be excluded");
472 }
473
474 #[test]
475 fn generate_spec_writer_scopes_commands() {
476 let tmp = tempfile::tempdir().unwrap();
477 let out = generate(tmp.path(), Some("spec-writer"), None, &sample_commands(), None).unwrap();
478
479 let cr_pos = out.find("## Command Reference").unwrap();
480 let cr_section = &out[cr_pos..];
481
482 assert!(cr_section.contains("apm show"), "'apm show' missing for spec-writer");
484 assert!(cr_section.contains("apm state"), "'apm state' missing for spec-writer");
485 assert!(cr_section.contains("apm spec"), "'apm spec' missing for spec-writer");
486 assert!(cr_section.contains("apm set"), "'apm set' missing for spec-writer");
487 assert!(cr_section.contains("apm new"), "'apm new' missing for spec-writer");
488 assert!(cr_section.contains("apm instructions"), "'apm instructions' missing for spec-writer");
489
490 assert!(!cr_section.contains("apm start"), "'apm start' found in spec-writer command reference but should be excluded");
492 }
493
494 #[test]
495 fn generate_unknown_role_gets_worker_allowlist() {
496 let tmp = tempfile::tempdir().unwrap();
497 let out = generate(tmp.path(), Some("unknown-role-xyz"), None, &sample_commands(), None).unwrap();
498
499 let cr_pos = out.find("## Command Reference").unwrap();
500 let cr_section = &out[cr_pos..];
501
502 assert!(cr_section.contains("apm show"), "'apm show' missing for unknown role");
504 assert!(cr_section.contains("apm state"), "'apm state' missing for unknown role");
505 assert!(cr_section.contains("apm spec"), "'apm spec' missing for unknown role");
506 assert!(cr_section.contains("apm set"), "'apm set' missing for unknown role");
507 assert!(cr_section.contains("apm new"), "'apm new' missing for unknown role");
508 assert!(cr_section.contains("apm instructions"), "'apm instructions' missing for unknown role");
509
510 assert!(!cr_section.contains("apm prompt"), "'apm prompt' found for unknown role but should be excluded");
512 }
513
514 #[test]
515 fn generate_with_id_no_placeholder_remains() {
516 let tmp = tempfile::tempdir().unwrap();
517 let out = generate(tmp.path(), Some("worker"), Some("abc12345"), &[], None).unwrap();
518 assert!(!out.contains("<id>"), "no <id> placeholder should remain after substitution");
519 assert!(out.contains("abc12345"), "ticket id should appear in output");
520 }
521
522 #[test]
523 fn imperative_table_format_header() {
524 let config_toml = r#"
525[project]
526name = "test"
527
528[tickets]
529dir = "tickets"
530
531[[workflow.states]]
532id = "ready"
533label = "Ready"
534
535[[workflow.states.transitions]]
536to = "in_progress"
537trigger = "command:start"
538
539[[workflow.states]]
540id = "in_progress"
541label = "In Progress"
542worker_profile = "claude/coder"
543
544[[workflow.states.transitions]]
545to = "implemented"
546trigger = "done"
547"#;
548 let tmp = tempfile::tempdir().unwrap();
549 let apm_dir = tmp.path().join(".apm");
550 std::fs::create_dir_all(&apm_dir).unwrap();
551 std::fs::write(apm_dir.join("config.toml"), config_toml).unwrap();
552
553 let out = generate(tmp.path(), Some("coder"), None, &[], None).unwrap();
554 let sm_pos = out.find("## State Machine").unwrap();
556 let sm_section = &out[sm_pos..];
557 assert!(
558 sm_section.contains("| From | To | Command |"),
559 "table header missing from state machine section; got:\n{sm_section}"
560 );
561 }
562
563 #[test]
564 fn live_state_machine_filters_by_role() {
565
566 let config_toml = r#"
567[project]
568name = "test"
569
570[tickets]
571dir = "tickets"
572
573[[workflow.states]]
574id = "ready"
575label = "Ready"
576worker_profile = "claude/coder"
577
578[[workflow.states.transitions]]
579to = "in_progress"
580trigger = "start"
581
582[[workflow.states]]
583id = "in_progress"
584label = "In Progress"
585worker_profile = "claude/coder"
586
587[[workflow.states.transitions]]
588to = "implemented"
589trigger = "done"
590
591[[workflow.states]]
592id = "implemented"
593label = "Implemented"
594
595[[workflow.states.transitions]]
596to = "closed"
597trigger = "approve"
598
599[[workflow.states]]
600id = "groomed"
601label = "Groomed"
602worker_profile = "claude/spec-writer"
603
604[[workflow.states.transitions]]
605to = "in_design"
606trigger = "claim"
607
608[[workflow.states]]
609id = "in_design"
610label = "In Design"
611worker_profile = "claude/spec-writer"
612
613[[workflow.states.transitions]]
614to = "specd"
615trigger = "submit"
616
617[[workflow.states]]
618id = "specd"
619label = "Specd"
620
621[[workflow.states]]
622id = "closed"
623label = "Closed"
624terminal = true
625"#;
626 let tmp = tempfile::tempdir().unwrap();
627 let apm_dir = tmp.path().join(".apm");
628 std::fs::create_dir_all(&apm_dir).unwrap();
629 std::fs::write(apm_dir.join("config.toml"), config_toml).unwrap();
630
631 let commands: Vec<(String, String)> = vec![];
632
633 fn state_machine_section(out: &str) -> &str {
635 let start = out.find("## State Machine\n").unwrap();
636 let end = out.find("## Ticket Format\n").unwrap();
637 &out[start..end]
638 }
639
640 let out = generate(tmp.path(), Some("coder"), None, &commands, None).unwrap();
642 let sm = state_machine_section(&out);
643 assert!(sm.contains("in_progress"), "in_progress missing for coder");
644 assert!(sm.contains("ready"), "ready (source of coder transition) missing");
645 assert!(sm.contains("implemented"), "implemented (target of coder transition) missing");
646 assert!(!sm.contains("groomed"), "groomed should not appear for coder role");
647 assert!(!sm.contains("in_design"), "in_design should not appear for coder role");
648 assert!(!sm.contains("specd"), "specd should not appear for coder role");
649
650 let out = generate(tmp.path(), Some("spec-writer"), None, &commands, None).unwrap();
652 let sm = state_machine_section(&out);
653 assert!(sm.contains("groomed"), "groomed missing for spec-writer");
654 assert!(sm.contains("in_design"), "in_design missing for spec-writer");
655 assert!(sm.contains("specd"), "specd (target) missing for spec-writer");
656 assert!(!sm.contains("ready"), "ready should not appear in state machine for spec-writer role");
657 assert!(!sm.contains("in_progress"), "in_progress should not appear in state machine for spec-writer role");
658 }
659
660 #[test]
661 fn live_ticket_format_from_config() {
662 let config_toml = r#"
663[project]
664name = "test"
665
666[tickets]
667dir = "tickets"
668
669[[ticket.sections]]
670name = "Problem"
671type = "free"
672required = true
673placeholder = "What is broken?"
674
675[[ticket.sections]]
676name = "Acceptance criteria"
677type = "tasks"
678required = true
679
680[[ticket.sections]]
681name = "Open questions"
682type = "qa"
683"#;
684 let tmp = tempfile::tempdir().unwrap();
685 let apm_dir = tmp.path().join(".apm");
686 std::fs::create_dir_all(&apm_dir).unwrap();
687 std::fs::write(apm_dir.join("config.toml"), config_toml).unwrap();
688
689 let out = generate(tmp.path(), Some("worker"), None, &[], None).unwrap();
691 assert!(out.contains("Problem"), "Problem section missing");
692 assert!(out.contains("Acceptance criteria"), "Acceptance criteria missing");
693 assert!(out.contains("Open questions"), "Open questions missing");
694 assert!(out.contains("required"), "required flag missing");
695 }
696
697 fn exit_section(out: &str) -> &str {
698 let start = out.find("## Exit scenarios\n").expect("## Exit scenarios missing");
699 let end = out[start..].find("\n## ").map(|i| start + i + 1).unwrap_or(out.len());
700 &out[start..end]
701 }
702
703 #[test]
704 fn exit_scenarios_only_hinted_transitions() {
705 let config_toml = r#"
706[project]
707name = "test"
708
709[tickets]
710dir = "tickets"
711
712[[workflow.states]]
713id = "in_progress"
714label = "In Progress"
715worker_profile = "claude/coder"
716
717 [[workflow.states.transitions]]
718 to = "implemented"
719 trigger = "manual"
720 worker_hint = "If you completed the implementation and tests pass"
721
722 [[workflow.states.transitions]]
723 to = "blocked"
724 trigger = "manual"
725
726[[workflow.states]]
727id = "specd"
728label = "Specd"
729
730 [[workflow.states.transitions]]
731 to = "closed"
732 trigger = "manual"
733 worker_hint = "Supervisor only: close the spec"
734"#;
735 let tmp = tempfile::tempdir().unwrap();
736 let apm_dir = tmp.path().join(".apm");
737 std::fs::create_dir_all(&apm_dir).unwrap();
738 std::fs::write(apm_dir.join("config.toml"), config_toml).unwrap();
739
740 let out = generate(tmp.path(), Some("coder"), Some("abc12345"), &[], Some("in_progress")).unwrap();
742 assert!(out.contains("## Exit scenarios"), "exit scenarios section must appear");
743 let es = exit_section(&out);
744 assert_eq!(es.matches("### If you completed").count(), 1, "exactly one hinted scenario");
745 assert!(!es.contains("### ") || es.matches("### ").count() == 1, "only the hinted scenario must have a heading");
747 assert!(!es.contains("apm state abc12345 blocked"), "un-hinted blocked transition must be absent from exit scenarios");
748 assert!(!out.contains("Supervisor only"), "supervisor-state scenarios must be absent (no worker_profile on specd)");
749
750 let out_no_id = generate(tmp.path(), Some("coder"), None, &[], Some("in_progress")).unwrap();
752 assert!(!out_no_id.contains("## Exit scenarios"), "exit scenarios must not appear without ticket id");
753
754 let out_no_state = generate(tmp.path(), Some("coder"), Some("abc12345"), &[], None).unwrap();
756 assert!(!out_no_state.contains("## Exit scenarios"), "exit scenarios must not appear without current_state");
757 }
758
759 #[test]
760 fn exit_scenarios_worker_pre_before_apm_state() {
761 let config_toml = r#"
762[project]
763name = "test"
764
765[tickets]
766dir = "tickets"
767
768[[workflow.states]]
769id = "in_progress"
770label = "In Progress"
771worker_profile = "claude/coder"
772
773 [[workflow.states.transitions]]
774 to = "blocked"
775 trigger = "manual"
776 worker_hint = "If you lack information to proceed"
777 worker_pre = "apm spec <id> --section 'Open questions' --append '<your question text>'"
778"#;
779 let tmp = tempfile::tempdir().unwrap();
780 let apm_dir = tmp.path().join(".apm");
781 std::fs::create_dir_all(&apm_dir).unwrap();
782 std::fs::write(apm_dir.join("config.toml"), config_toml).unwrap();
783
784 let out = generate(tmp.path(), Some("coder"), Some("abc12345"), &[], Some("in_progress")).unwrap();
785 let es = exit_section(&out);
786 let pre_pos = es.find("apm spec abc12345 --section 'Open questions'").expect("worker_pre must appear in exit scenarios");
787 let state_pos = es.find("apm state abc12345 blocked").expect("apm state line must appear in exit scenarios");
788 assert!(pre_pos < state_pos, "worker_pre must come before apm state");
789 }
790
791 #[test]
792 fn exit_scenarios_default_workflow_in_progress() {
793 let default_workflow = include_str!("default/workflow.toml");
794 let tmp = tempfile::tempdir().unwrap();
795 let apm_dir = tmp.path().join(".apm");
796 std::fs::create_dir_all(&apm_dir).unwrap();
797 std::fs::write(apm_dir.join("config.toml"), "[project]\nname = \"test\"\n[tickets]\ndir = \"tickets\"\n").unwrap();
798 std::fs::write(apm_dir.join("workflow.toml"), default_workflow).unwrap();
799
800 let out = generate(tmp.path(), Some("coder"), Some("abc12345"), &[], Some("in_progress")).unwrap();
801 assert!(out.contains("## Exit scenarios"), "exit scenarios must appear for in_progress");
802 let impl_pos = out.find("If you completed the implementation and tests pass").expect("implemented scenario missing");
803 let blocked_pos = out.find("If you lack information to proceed").expect("blocked scenario missing");
804 assert!(impl_pos < blocked_pos, "implemented scenario must precede blocked scenario");
805 }
806}