Skip to main content

plexus_substrate/activations/orcha/
ticket_compiler.rs

1use super::types::{OrchaEdgeDef, OrchaNodeDef, OrchaNodeSpec};
2use std::collections::HashMap;
3
4// ─── Public API ───────────────────────────────────────────────────────────────
5
6pub struct CompiledGraph {
7    pub nodes: Vec<OrchaNodeDef>,
8    pub edges: Vec<OrchaEdgeDef>,
9}
10
11/// Compile a Markdown plan document into a graph definition.
12///
13/// # Ticket format
14///
15/// Each ticket starts at a level-1 heading that includes a `[type]` tag:
16///
17/// ```markdown
18/// # UX-4: Move ir.json Out of Output Directory [agent]
19///
20/// blocked_by: [UX-2]
21/// validate: cargo test -- ir_location_tests
22///
23/// ## Problem
24///
25/// The ir.json file is written into the user-facing output directory...
26///
27/// ## Acceptance Criteria
28///
29/// - ./generated/ contains only TypeScript files
30/// ```
31///
32/// Everything before the first matching heading is preamble and is ignored.
33/// All body content — including `##` subheadings and code blocks — becomes
34/// the task prompt (for agent types) or the shell command (for `[prog]`).
35///
36/// # Supported types
37///
38/// - `[agent]` — Claude runs the full body as a task prompt
39/// - `[agent/synthesize]` — like agent, prepends prior-work context from upstream tokens
40/// - `[prog]` — the body (minus metadata lines) is a shell command; exit 0 = pass
41///
42/// # Body metadata (parsed and stripped from the prompt)
43///
44/// - `blocked_by: [dep1, dep2]` — dependency list; also accepts `blocked_by: dep1, dep2`
45/// - `unlocks: [...]` — informational only, ignored by the compiler
46/// - `validate: <shell command>` — auto-generates a sibling `<ID>-validate` node
47///
48/// # Validate sibling rewriting
49///
50/// If `UX-4 [agent]` has `validate: cargo test`, the compiler creates:
51/// - `UX-4` — Task node
52/// - `UX-4-validate` — Validate node running `cargo test`
53/// - Any ticket with `blocked_by: [UX-4]` is rewritten to depend on `UX-4-validate`
54///   so downstream work only starts after validation passes.
55pub fn compile_tickets(input: &str) -> Result<CompiledGraph, String> {
56    let sections = parse_sections(input);
57    build_graph(sections)
58}
59
60// ─── Section parsing ──────────────────────────────────────────────────────────
61
62struct RawSection {
63    id: String,
64    type_tag: String,
65    body_lines: Vec<String>,
66}
67
68/// Split the document into per-ticket sections.
69///
70/// Only `# ID: Title [type]` headings (level 1 with a `[type]` tag and a
71/// space-free ID) create new sections.  All other content — including `## `
72/// subheadings — is captured as part of the current section's body.
73/// Lines before the first ticket heading are preamble and are discarded.
74fn parse_sections(input: &str) -> Vec<RawSection> {
75    let mut sections: Vec<RawSection> = Vec::new();
76    let mut current: Option<(String, String, Vec<String>)> = None;
77
78    for line in input.lines() {
79        if let Some((id, type_tag)) = try_parse_ticket_heading(line) {
80            if let Some((prev_id, prev_type, lines)) = current.take() {
81                sections.push(RawSection { id: prev_id, type_tag: prev_type, body_lines: lines });
82            }
83            current = Some((id, type_tag, Vec::new()));
84        } else if let Some((_, _, ref mut lines)) = current {
85            lines.push(line.to_string());
86        }
87        // else: preamble — skip
88    }
89    if let Some((id, type_tag, lines)) = current {
90        sections.push(RawSection { id, type_tag, body_lines: lines });
91    }
92    sections
93}
94
95/// Try to parse `# ID: Title [type]` → `Some((id, type_tag))`.
96///
97/// Rules:
98/// - Must start with exactly `# ` (not `## `)
99/// - Must contain `[type]` somewhere on the line
100/// - ID is the first token before `:` or `[`, must be non-empty and contain no spaces
101fn try_parse_ticket_heading(line: &str) -> Option<(String, String)> {
102    // Exactly "# " — not "## " or deeper
103    let rest = line.strip_prefix("# ")?;
104    if rest.starts_with('#') {
105        return None;
106    }
107    let rest = rest.trim();
108
109    // Must have [type] bracket
110    let bracket_open = rest.find('[')?;
111    let after_open = &rest[bracket_open + 1..];
112    let bracket_close = after_open.find(']')?;
113    let type_tag = after_open[..bracket_close].trim().to_string();
114    if type_tag.is_empty() {
115        return None;
116    }
117
118    // ID: everything before the first '[' or ':', must be a single token (no spaces)
119    let before_bracket = rest[..bracket_open].trim();
120    let id = before_bracket
121        .split(':')
122        .next()
123        .unwrap_or(before_bracket)
124        .trim()
125        .to_string();
126
127    if id.is_empty() || id.contains(' ') {
128        return None;
129    }
130
131    Some((id, type_tag))
132}
133
134// ─── Graph building ───────────────────────────────────────────────────────────
135
136struct ParsedTicket {
137    id: String,
138    type_tag: String,
139    deps: Vec<String>,
140    /// Task prompt for agent types (body minus metadata lines)
141    task: Option<String>,
142    /// Shell command for prog types (body minus metadata lines)
143    command: Option<String>,
144    /// Inline validate command (generates a sibling validate node)
145    validate: Option<String>,
146}
147
148fn build_graph(sections: Vec<RawSection>) -> Result<CompiledGraph, String> {
149    let parsed: Vec<ParsedTicket> = sections
150        .into_iter()
151        .map(parse_section_body)
152        .collect::<Result<_, _>>()?;
153
154    // completion_id maps a ticket id to the id of the last node in its chain.
155    // If a ticket has a validate sibling, anything blocked_by that ticket
156    // must wait for the validate sibling instead.
157    let mut completion_id: HashMap<String, String> = HashMap::new();
158    for t in &parsed {
159        let effective = if t.validate.is_some() {
160            format!("{}-validate", t.id)
161        } else {
162            t.id.clone()
163        };
164        completion_id.insert(t.id.clone(), effective);
165    }
166
167    let mut nodes: Vec<OrchaNodeDef> = Vec::new();
168    let mut edges: Vec<OrchaEdgeDef> = Vec::new();
169
170    for t in &parsed {
171        // Primary node
172        let spec = match t.type_tag.as_str() {
173            "agent" => {
174                let task = t.task.clone().ok_or_else(|| {
175                    format!("Ticket '{}' [agent] has no body text", t.id)
176                })?;
177                OrchaNodeSpec::Task { task, max_retries: None }
178            }
179            "agent/synthesize" => {
180                let task = t.task.clone().ok_or_else(|| {
181                    format!("Ticket '{}' [agent/synthesize] has no body text", t.id)
182                })?;
183                OrchaNodeSpec::Synthesize { task, max_retries: None }
184            }
185            "prog" => {
186                let command = t.command.clone().ok_or_else(|| {
187                    format!("Ticket '{}' [prog] has no body text", t.id)
188                })?;
189                OrchaNodeSpec::Validate { command, cwd: None, max_retries: None }
190            }
191            "review" => {
192                let prompt = t.task.clone().ok_or_else(|| {
193                    format!("Ticket '{}' [review] has no body text", t.id)
194                })?;
195                OrchaNodeSpec::Review { prompt }
196            }
197            "planner" => {
198                let task = t.task.clone().ok_or_else(|| {
199                    format!("Ticket '{}' [planner] has no body text", t.id)
200                })?;
201                OrchaNodeSpec::Plan { task }
202            }
203            other => {
204                return Err(format!(
205                    "Unknown ticket type [{}] in ticket '{}'",
206                    other, t.id
207                ))
208            }
209        };
210        nodes.push(OrchaNodeDef { id: t.id.clone(), spec });
211
212        // Validate sibling node
213        if let Some(ref cmd) = t.validate {
214            nodes.push(OrchaNodeDef {
215                id: format!("{}-validate", t.id),
216                spec: OrchaNodeSpec::Validate { command: cmd.clone(), cwd: None, max_retries: None },
217            });
218            // Edge: ticket → validate sibling
219            edges.push(OrchaEdgeDef {
220                from: t.id.clone(),
221                to: format!("{}-validate", t.id),
222            });
223        }
224
225        // Dependency edges.
226        // If the dep is a ticket in this document AND has a validate sibling,
227        // rewrite to point at the sibling.  Otherwise pass the id through as-is
228        // (allows referencing externally-built lattice nodes).
229        for dep in &t.deps {
230            let effective_dep = completion_id
231                .get(dep)
232                .cloned()
233                .unwrap_or_else(|| dep.clone());
234            edges.push(OrchaEdgeDef { from: effective_dep, to: t.id.clone() });
235        }
236    }
237
238    Ok(CompiledGraph { nodes, edges })
239}
240
241fn parse_section_body(section: RawSection) -> Result<ParsedTicket, String> {
242    let RawSection { id, type_tag, body_lines } = section;
243
244    let mut deps: Vec<String> = Vec::new();
245    let mut validate: Option<String> = None;
246    let mut prose_lines: Vec<String> = Vec::new();
247
248    for line in &body_lines {
249        let trimmed = line.trim();
250
251        // Skip comment lines
252        if trimmed.starts_with("<!--") || trimmed.starts_with("//") {
253            continue;
254        }
255
256        // blocked_by: [dep1, dep2]  or  blocked_by: dep1, dep2
257        if let Some(rest) = trimmed
258            .strip_prefix("blocked_by:")
259            .or_else(|| trimmed.strip_prefix("blocked-by:"))
260        {
261            let list = rest.trim().trim_start_matches('[').trim_end_matches(']');
262            deps = list
263                .split(',')
264                .map(|s| s.trim().to_string())
265                .filter(|s| !s.is_empty())
266                .collect();
267            continue;
268        }
269
270        // validate: <command>  (single line)
271        if let Some(cmd) = trimmed.strip_prefix("validate:") {
272            let cmd = cmd.trim().to_string();
273            if !cmd.is_empty() {
274                validate = Some(cmd);
275            }
276            continue;
277        }
278
279        // unlocks: — informational only, discard
280        if trimmed.starts_with("unlocks:") {
281            continue;
282        }
283
284        prose_lines.push(line.to_string());
285    }
286
287    // Trim leading/trailing blank lines, preserve internal structure
288    let start = prose_lines.iter().position(|l| !l.trim().is_empty()).unwrap_or(prose_lines.len());
289    let end = prose_lines
290        .iter()
291        .rposition(|l| !l.trim().is_empty())
292        .map(|i| i + 1)
293        .unwrap_or(0);
294    let body = if start < end { prose_lines[start..end].join("\n") } else { String::new() };
295
296    let (task, command) = match type_tag.as_str() {
297        "prog" => (None, if body.is_empty() { None } else { Some(body) }),
298        _ => (if body.is_empty() { None } else { Some(body) }, None),
299    };
300
301    Ok(ParsedTicket { id, type_tag, deps, task, command, validate })
302}
303
304// ─── Tests ────────────────────────────────────────────────────────────────────
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_basic_agent_ticket() {
312        let input = "\
313# T01: Write the parser [agent]
314
315Implement a JSON webhook parser with typed errors.
316";
317        let g = compile_tickets(input).unwrap();
318        assert_eq!(g.nodes.len(), 1);
319        assert_eq!(g.edges.len(), 0);
320        match &g.nodes[0].spec {
321            OrchaNodeSpec::Task { task, .. } => assert!(task.contains("JSON webhook parser")),
322            _ => panic!("wrong spec"),
323        }
324    }
325
326    #[test]
327    fn test_preamble_is_skipped() {
328        let input = "\
329# My Epic Plan
330
331This is an overview document with context and background.
332
333## Architecture
334
335Some architecture notes here.
336
337# T01: First ticket [agent]
338
339Do the thing.
340";
341        let g = compile_tickets(input).unwrap();
342        // The first '# My Epic Plan' has no [type], so it's preamble
343        assert_eq!(g.nodes.len(), 1);
344        assert_eq!(g.nodes[0].id, "T01");
345    }
346
347    #[test]
348    fn test_validate_sibling() {
349        let input = "\
350# T01: Write it [agent]
351
352Implement the feature.
353
354blocked_by: []
355validate: cargo test -- feature_tests
356";
357        let g = compile_tickets(input).unwrap();
358        assert_eq!(g.nodes.len(), 2);
359        assert!(g.nodes.iter().any(|n| n.id == "T01"));
360        assert!(g.nodes.iter().any(|n| n.id == "T01-validate"));
361        assert_eq!(g.edges.len(), 1);
362        assert_eq!(g.edges[0], OrchaEdgeDef { from: "T01".into(), to: "T01-validate".into() });
363    }
364
365    #[test]
366    fn test_dep_rewriting_through_validate() {
367        let input = "\
368# T01: First [agent]
369
370Do the first thing.
371
372validate: cargo test -- t01
373
374# T02: Second [agent]
375
376Do the second thing.
377
378blocked_by: [T01]
379";
380        let g = compile_tickets(input).unwrap();
381        // T01, T01-validate, T02
382        assert_eq!(g.nodes.len(), 3);
383
384        let edge_pairs: Vec<(&str, &str)> =
385            g.edges.iter().map(|e| (e.from.as_str(), e.to.as_str())).collect();
386
387        // T01 → T01-validate (validate sibling edge)
388        assert!(edge_pairs.contains(&("T01", "T01-validate")));
389        // T02 blocked_by T01 → rewritten to depend on T01-validate
390        assert!(edge_pairs.contains(&("T01-validate", "T02")));
391        // NOT T01 → T02 directly
392        assert!(!edge_pairs.contains(&("T01", "T02")));
393    }
394
395    #[test]
396    fn test_prog_ticket() {
397        let input = "\
398# validate-build [prog]
399
400blocked_by: [T01]
401cargo build --release 2>&1 | grep -c '^error' | xargs test 0 -eq
402";
403        let g = compile_tickets(input).unwrap();
404        assert_eq!(g.nodes.len(), 1);
405        match &g.nodes[0].spec {
406            OrchaNodeSpec::Validate { command, .. } => {
407                assert!(command.contains("cargo build"));
408            }
409            _ => panic!("wrong spec"),
410        }
411    }
412
413    #[test]
414    fn test_subsections_become_prose() {
415        let input = "\
416# UX-4: Move ir.json [agent]
417
418blocked_by: [UX-2]
419unlocks: [UX-9]
420
421## Problem
422
423The ir.json file is written into the wrong place.
424
425## Acceptance Criteria
426
427- Output dir contains only TypeScript files
428- ir.json lives in cache
429";
430        let g = compile_tickets(input).unwrap();
431        assert_eq!(g.nodes.len(), 1);
432        match &g.nodes[0].spec {
433            OrchaNodeSpec::Task { task, .. } => {
434                assert!(task.contains("## Problem"));
435                assert!(task.contains("## Acceptance Criteria"));
436                assert!(task.contains("ir.json"));
437                // metadata lines are NOT in the prompt
438                assert!(!task.contains("blocked_by"));
439                assert!(!task.contains("unlocks"));
440            }
441            _ => panic!("wrong spec"),
442        }
443        // blocked_by UX-2 parsed correctly
444        assert_eq!(g.edges.len(), 1);
445        assert_eq!(g.edges[0].from, "UX-2");
446        assert_eq!(g.edges[0].to, "UX-4");
447    }
448
449    #[test]
450    fn test_synthesize_type() {
451        let input = "\
452# T03: Synthesize report [agent/synthesize]
453
454Review all prior work and write a final integration report.
455";
456        let g = compile_tickets(input).unwrap();
457        assert!(matches!(&g.nodes[0].spec, OrchaNodeSpec::Synthesize { .. }));
458    }
459
460    #[test]
461    fn test_multiple_deps() {
462        let input = "\
463# A [agent]
464Task A.
465
466# B [agent]
467Task B.
468
469# C [agent]
470Task C.
471
472blocked_by: [A, B]
473";
474        let g = compile_tickets(input).unwrap();
475        let edge_pairs: Vec<(&str, &str)> =
476            g.edges.iter().map(|e| (e.from.as_str(), e.to.as_str())).collect();
477        assert!(edge_pairs.contains(&("A", "C")));
478        assert!(edge_pairs.contains(&("B", "C")));
479    }
480}
481
482impl PartialEq for OrchaEdgeDef {
483    fn eq(&self, other: &Self) -> bool {
484        self.from == other.from && self.to == other.to
485    }
486}