Skip to main content

apm_core/
instructions.rs

1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::Path;
4
5use crate::config::Config;
6
7// ---------------------------------------------------------------------------
8// Static fallback content
9// ---------------------------------------------------------------------------
10
11static 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
89// ---------------------------------------------------------------------------
90// Public entry point
91// ---------------------------------------------------------------------------
92
93/// Generate full APM system-knowledge text.
94///
95/// - `root` — project root used to load `Config` (workflow + ticket config).
96///   Falls back to static built-in descriptions when config is absent.
97/// - `role` — optional role name (e.g. `"worker"`, `"spec-writer"`).
98///   When absent, returns a role index listing available roles instead of
99///   the full system-knowledge sections.
100/// - `ticket_id` — optional ticket id. When present, every occurrence of the
101///   literal placeholder `<id>` in the rendered output is substituted.
102/// - `commands` — `(name, about)` pairs extracted from the CLI by the caller.
103///   Keeps `apm-core` free of a clap dependency.
104///
105/// Returns a plain-text string with no ANSI escape codes.
106pub fn generate(root: &Path, role: Option<&str>, ticket_id: Option<&str>, commands: &[(String, String)], current_state: Option<&str>) -> Result<String> {
107    // No-role: return role index immediately (no state machine, no sections).
108    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    // 1. State machine
116    out.push_str("## State Machine\n\n");
117    out.push_str(&state_machine_body(config.as_ref(), role));
118
119    // 2. Exit scenarios (only when ticket id is supplied)
120    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    // 3. Ticket format
130    out.push_str("## Ticket Format\n\n");
131    out.push_str(&ticket_format_body(config.as_ref()));
132
133    // 4. Session identity
134    out.push_str("## Session Identity\n\n");
135    out.push_str(SESSION_IDENTITY_BODY);
136    out.push('\n');
137
138    // 5. Command reference — omit section entirely when no commands are provided
139    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    // Ticket-id substitution: replace every <id> placeholder with the actual id.
146    if let Some(id) = ticket_id {
147        out = out.replace("<id>", id);
148    }
149
150    Ok(out)
151}
152
153// ---------------------------------------------------------------------------
154// Section builders
155// ---------------------------------------------------------------------------
156
157fn 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; // "apm " prefix
308
309    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
318// ---------------------------------------------------------------------------
319// Helpers
320// ---------------------------------------------------------------------------
321
322const WORKER_COMMAND_ALLOWLIST: &[&str] = &["show", "state", "spec", "set", "new", "instructions"];
323
324/// Name + description tuples for the six worker-permitted `apm` commands.
325/// Names must stay in sync with WORKER_COMMAND_ALLOWLIST (ticket 9c66e199).
326/// Descriptions are purpose-built for agent consumption; they are NOT copied
327/// from clap `///` doc comments. If a subcommand's fundamental purpose changes,
328/// update both this const and the clap string in apm/src/main.rs in the same commit.
329pub(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// ---------------------------------------------------------------------------
371// Tests
372// ---------------------------------------------------------------------------
373
374#[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        // Use sample_commands so Command Reference is present for ordering check
412        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        // State machine must use table format
441        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        // Six unified commands are present
461        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        // Supervisor commands excluded
469        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        // Six unified commands are present
483        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        // Supervisor commands excluded
491        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        // Six unified commands are present for any role
503        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        // Supervisor commands excluded
511        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        // State machine section must use table format
555        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        // Helper: extract just the state machine section (between ## State Machine and ## Ticket Format)
634        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        // Coder role: should include ready, in_progress, implemented but not groomed/specd/in_design
641        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        // spec-writer role: should include groomed, in_design, specd but not ready/in_progress
651        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        // Use role = Some("worker") — no-role now returns role index, not ticket format.
690        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        // with worker-profile state and ticket id: exit scenarios present
741        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        // un-hinted "blocked" transition has no worker_hint so no heading for it in exit scenarios
746        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        // no ticket id: no exit scenarios
751        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        // no current state: no exit scenarios
755        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}