1use std::collections::{BTreeMap, BTreeSet, HashMap};
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use anyhow::{bail, Context, Result};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16pub const PROMPT_FILENAME: &str = "PROMPT.md";
17const MAX_INCLUDE_DEPTH: usize = 8;
18const MAX_EXPANDED_BYTES: usize = 1024 * 1024;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct RunSpec {
22 pub version: u32,
23 pub objective: Option<String>,
24 #[serde(default)]
25 pub tasks: RunSpecTasks,
26 #[serde(default)]
27 pub tranches: Option<RunSpecTranches>,
28 #[serde(default)]
29 pub plan_guard: Option<RunSpecPlanGuard>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
33pub struct RunSpecTasks {
34 #[serde(default)]
35 pub items: Vec<RunSpecTask>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct RunSpecTask {
40 pub key: String,
41 pub cmd: String,
42 #[serde(default)]
43 pub class: Option<String>,
44 #[serde(default)]
45 pub depends_on: Vec<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct RunSpecTranches {
50 pub items: Vec<RunSpecTranche>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct RunSpecTranche {
55 pub key: String,
56 #[serde(default)]
57 pub objective: Option<String>,
58 pub task_keys: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct RunSpecPlanGuard {
63 pub target: String,
64 #[serde(default)]
65 pub mode: RunSpecPlanGuardMode,
66}
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "kebab-case")]
70pub enum RunSpecPlanGuardMode {
71 Implement,
72 VerifyOnly,
73}
74
75impl Default for RunSpecPlanGuardMode {
76 fn default() -> Self {
77 Self::Implement
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct PromptSnapshot {
83 pub entry_path: String,
84 pub expanded_sha256: String,
85 pub included_files: Vec<PromptFileHash>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct PromptFileHash {
90 pub path: String,
91 pub sha256: String,
92}
93
94#[derive(Debug, Clone)]
95pub struct LoadedPrompt {
96 pub entry_path: PathBuf,
97 pub expanded_text: String,
98 pub snapshot: PromptSnapshot,
99 pub run_spec: RunSpec,
100}
101
102#[derive(Debug, Clone)]
103pub struct LoadedPromptOptionalRunSpec {
104 pub entry_path: PathBuf,
105 pub expanded_text: String,
106 pub snapshot: PromptSnapshot,
107 pub run_spec: Option<RunSpec>,
108}
109
110pub fn load_prompt_and_run_spec_from_cwd() -> Result<LoadedPrompt> {
111 let entry_path =
112 find_prompt_upwards(std::env::current_dir()?).context("failed to resolve PROMPT.md")?;
113 load_prompt_and_run_spec(&entry_path)
114}
115
116pub fn find_prompt_upwards(mut dir: PathBuf) -> Result<PathBuf> {
117 loop {
118 let candidate = dir.join(PROMPT_FILENAME);
119 if candidate.exists() {
120 return Ok(candidate);
121 }
122 if !dir.pop() {
123 bail!(
124 "{} not found (run from repo root or create PROMPT.md)",
125 PROMPT_FILENAME
126 );
127 }
128 }
129}
130
131pub fn load_prompt_and_run_spec(entry_prompt_path: &Path) -> Result<LoadedPrompt> {
132 let loaded = load_prompt_with_optional_run_spec(entry_prompt_path)?;
133 let Some(run_spec) = loaded.run_spec else {
134 bail!("PROMPT.md must contain exactly one ```yarli-run fenced block (found 0)");
135 };
136
137 Ok(LoadedPrompt {
138 entry_path: loaded.entry_path,
139 expanded_text: loaded.expanded_text,
140 snapshot: loaded.snapshot,
141 run_spec,
142 })
143}
144
145pub fn load_prompt_with_optional_run_spec(
146 entry_prompt_path: &Path,
147) -> Result<LoadedPromptOptionalRunSpec> {
148 let (entry_prompt_path, expanded, snapshot) = load_expanded_prompt(entry_prompt_path)?;
149 let run_spec = parse_run_spec_block(&expanded, false)?;
150 Ok(LoadedPromptOptionalRunSpec {
151 entry_path: entry_prompt_path,
152 expanded_text: expanded,
153 snapshot,
154 run_spec,
155 })
156}
157
158fn load_expanded_prompt(entry_prompt_path: &Path) -> Result<(PathBuf, String, PromptSnapshot)> {
159 let entry_prompt_path = entry_prompt_path
160 .canonicalize()
161 .with_context(|| format!("failed to canonicalize {}", entry_prompt_path.display()))?;
162 let base_dir = entry_prompt_path
163 .parent()
164 .context("PROMPT.md has no parent directory")?
165 .to_path_buf();
166
167 let mut included_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
168 let mut visiting: BTreeSet<PathBuf> = BTreeSet::new();
169 let mut expanded = String::new();
170 expand_file(
171 &base_dir,
172 &entry_prompt_path,
173 0,
174 &mut visiting,
175 &mut included_hashes,
176 &mut expanded,
177 )?;
178
179 if expanded.len() > MAX_EXPANDED_BYTES {
180 bail!(
181 "expanded prompt exceeds max size ({} > {} bytes)",
182 expanded.len(),
183 MAX_EXPANDED_BYTES
184 );
185 }
186
187 let expanded_sha256 = sha256_hex(expanded.as_bytes());
188 let entry_path_display = entry_prompt_path.display().to_string();
189 let included_files = included_hashes
190 .into_iter()
191 .map(|(path, sha)| PromptFileHash {
192 path: path.display().to_string(),
193 sha256: sha,
194 })
195 .collect::<Vec<_>>();
196
197 Ok((
198 entry_prompt_path,
199 expanded,
200 PromptSnapshot {
201 entry_path: entry_path_display,
202 expanded_sha256,
203 included_files,
204 },
205 ))
206}
207
208fn parse_run_spec_block(expanded: &str, require_block: bool) -> Result<Option<RunSpec>> {
209 let run_spec_blocks = extract_fenced_blocks(expanded, "yarli-run");
210 if run_spec_blocks.is_empty() {
211 if require_block {
212 bail!("PROMPT.md must contain exactly one ```yarli-run fenced block (found 0)");
213 }
214 return Ok(None);
215 }
216 if run_spec_blocks.len() != 1 {
217 bail!(
218 "PROMPT.md must contain exactly one ```yarli-run fenced block (found {})",
219 run_spec_blocks.len()
220 );
221 }
222
223 let run_spec: RunSpec = toml::from_str(&run_spec_blocks[0])
224 .context("failed to parse TOML in ```yarli-run block")?;
225 validate_run_spec(&run_spec)?;
226 Ok(Some(run_spec))
227}
228
229pub fn validate_run_spec(run_spec: &RunSpec) -> Result<()> {
230 if run_spec.version != 1 {
232 bail!(
233 "unsupported run spec version {} (expected 1)",
234 run_spec.version
235 );
236 }
237 let mut keys = BTreeSet::new();
238 for task in &run_spec.tasks.items {
239 if task.key.trim().is_empty() {
240 bail!("run spec task.key must be non-empty");
241 }
242 if task.cmd.trim().is_empty() {
243 bail!(
244 "run spec task.cmd must be non-empty (task key {})",
245 task.key
246 );
247 }
248 if !keys.insert(task.key.clone()) {
249 bail!("duplicate task key in run spec: {}", task.key);
250 }
251 }
252
253 validate_run_spec_task_dependencies(&run_spec.tasks.items)?;
254
255 if let Some(tranches) = run_spec.tranches.as_ref() {
256 if tranches.items.is_empty() {
257 bail!("run spec tranches.items must be non-empty when [tranches] is present");
258 }
259 let mut tranche_keys = BTreeSet::new();
260 let mut referenced = BTreeSet::new();
261 for tranche in &tranches.items {
262 if tranche.key.trim().is_empty() {
263 bail!("run spec tranche.key must be non-empty");
264 }
265 if !tranche_keys.insert(tranche.key.clone()) {
266 bail!("duplicate tranche key in run spec: {}", tranche.key);
267 }
268 if tranche.task_keys.is_empty() {
269 bail!(
270 "run spec tranche.task_keys must be non-empty (tranche key {})",
271 tranche.key
272 );
273 }
274 for task_key in &tranche.task_keys {
275 if !keys.contains(task_key) {
276 bail!(
277 "run spec tranche {} references unknown task key: {}",
278 tranche.key,
279 task_key
280 );
281 }
282 if !referenced.insert(task_key.clone()) {
283 bail!(
284 "run spec task key appears in multiple tranches: {}",
285 task_key
286 );
287 }
288 }
289 }
290 }
291
292 if let Some(plan_guard) = run_spec.plan_guard.as_ref() {
293 if plan_guard.target.trim().is_empty() {
294 bail!("run spec plan_guard.target must be non-empty");
295 }
296 }
297 Ok(())
298}
299
300fn validate_run_spec_task_dependencies(tasks: &[RunSpecTask]) -> Result<()> {
301 let available_keys: BTreeSet<_> = tasks.iter().map(|task| task.key.as_str()).collect();
302 let mut dependency_graph: HashMap<&str, Vec<&str>> = HashMap::new();
303
304 for task in tasks {
305 let task_key = task.key.trim();
306 if task_key.is_empty() {
307 continue;
308 }
309
310 let mut normalized_deps = Vec::new();
311 for dep in &task.depends_on {
312 let dep = dep.trim();
313 if dep.is_empty() {
314 bail!("run spec task {} has empty depends_on entry", task.key);
315 }
316 if dep == task_key {
317 bail!("run spec task {} cannot depend on itself", task.key);
318 }
319 if !available_keys.contains(dep) {
320 bail!(
321 "run spec task {} depends on unknown task key: {}",
322 task.key,
323 dep
324 );
325 }
326 normalized_deps.push(dep);
327 }
328 dependency_graph.insert(task_key, normalized_deps);
329 }
330
331 let mut visited = BTreeSet::new();
332 let mut visiting_stack = Vec::new();
333 let mut visiting_lookup = BTreeSet::new();
334
335 for task_key in available_keys {
336 if !visited.contains(task_key) {
337 detect_task_dependency_cycle(
338 task_key,
339 &dependency_graph,
340 &mut visited,
341 &mut visiting_stack,
342 &mut visiting_lookup,
343 )?;
344 }
345 }
346
347 Ok(())
348}
349
350#[allow(clippy::too_many_arguments)]
351fn detect_task_dependency_cycle<'a>(
352 task_key: &'a str,
353 dependency_graph: &HashMap<&str, Vec<&'a str>>,
354 visited: &mut BTreeSet<&'a str>,
355 visiting_stack: &mut Vec<&'a str>,
356 visiting_lookup: &mut BTreeSet<&'a str>,
357) -> Result<()> {
358 if visited.contains(task_key) {
359 return Ok(());
360 }
361 if visiting_lookup.contains(task_key) {
362 let cycle_start = visiting_stack
363 .iter()
364 .position(|value| *value == task_key)
365 .unwrap_or(0);
366 let cycle: Vec<&str> = visiting_stack[cycle_start..]
367 .iter()
368 .chain(std::iter::once(&task_key))
369 .copied()
370 .collect();
371 bail!(
372 "run spec has cyclic task dependency: {}",
373 cycle.join(" -> ")
374 );
375 }
376
377 visiting_lookup.insert(task_key);
378 visiting_stack.push(task_key);
379
380 for dep in dependency_graph.get(task_key).into_iter().flatten() {
381 detect_task_dependency_cycle(
382 dep,
383 dependency_graph,
384 visited,
385 visiting_stack,
386 visiting_lookup,
387 )?;
388 }
389
390 visiting_lookup.remove(task_key);
391 visiting_stack.pop();
392 visited.insert(task_key);
393 Ok(())
394}
395
396fn expand_file(
397 base_dir: &Path,
398 file_path: &Path,
399 depth: usize,
400 visiting: &mut BTreeSet<PathBuf>,
401 included_hashes: &mut BTreeMap<PathBuf, String>,
402 out: &mut String,
403) -> Result<()> {
404 if depth > MAX_INCLUDE_DEPTH {
405 bail!(
406 "include depth exceeded (>{}) at {}",
407 MAX_INCLUDE_DEPTH,
408 file_path.display()
409 );
410 }
411
412 let file_path = file_path
413 .canonicalize()
414 .with_context(|| format!("failed to canonicalize {}", file_path.display()))?;
415 ensure_repo_confined(base_dir, &file_path)?;
416
417 if !visiting.insert(file_path.clone()) {
418 bail!("include cycle detected at {}", file_path.display());
419 }
420
421 let raw = fs::read_to_string(&file_path)
422 .with_context(|| format!("failed to read {}", file_path.display()))?;
423 included_hashes.insert(file_path.clone(), sha256_hex(raw.as_bytes()));
424
425 out.push_str(&format!(
427 "\n<!-- BEGIN_INCLUDE path={} sha256={} -->\n",
428 file_path.display(),
429 sha256_hex(raw.as_bytes())
430 ));
431
432 for line in raw.lines() {
433 if let Some(path) = parse_include_line(line) {
434 let include_path = resolve_include_path(base_dir, &file_path, &path)?;
435 expand_file(
436 base_dir,
437 &include_path,
438 depth + 1,
439 visiting,
440 included_hashes,
441 out,
442 )?;
443 } else {
444 out.push_str(line);
445 out.push('\n');
446 }
447 }
448
449 out.push_str(&format!(
450 "<!-- END_INCLUDE path={} -->\n",
451 file_path.display()
452 ));
453
454 visiting.remove(&file_path);
455 Ok(())
456}
457
458fn parse_include_line(line: &str) -> Option<String> {
459 let trimmed = line.trim();
460 let rest = trimmed.strip_prefix("@include")?.trim();
461 if rest.is_empty() {
462 return None;
463 }
464 Some(rest.to_string())
465}
466
467fn resolve_include_path(base_dir: &Path, current_file: &Path, include: &str) -> Result<PathBuf> {
468 let include_path = Path::new(include);
469 if include_path.is_absolute() {
470 bail!("absolute include paths are not allowed: {include}");
471 }
472
473 let parent_dir = current_file
475 .parent()
476 .context("including file has no parent directory")?;
477 let candidate = parent_dir.join(include_path);
478
479 for comp in include_path.components() {
481 if matches!(
482 comp,
483 Component::ParentDir | Component::RootDir | Component::Prefix(_)
484 ) {
485 bail!("include path escape is not allowed: {include}");
486 }
487 }
488
489 let canonical = candidate.canonicalize().with_context(|| {
490 format!(
491 "failed to resolve include {include} from {}",
492 current_file.display()
493 )
494 })?;
495 ensure_repo_confined(base_dir, &canonical)?;
496 Ok(canonical)
497}
498
499fn ensure_repo_confined(repo_root: &Path, candidate: &Path) -> Result<()> {
500 let repo_root = repo_root
502 .canonicalize()
503 .with_context(|| format!("failed to canonicalize repo root {}", repo_root.display()))?;
504 if !candidate.starts_with(&repo_root) {
505 bail!(
506 "include path escapes repo root ({}): {}",
507 repo_root.display(),
508 candidate.display()
509 );
510 }
511 Ok(())
512}
513
514fn extract_fenced_blocks(markdown: &str, info: &str) -> Vec<String> {
515 let mut blocks = Vec::new();
517 let mut in_block = false;
518 let mut current = String::new();
519 let mut matches_info = false;
520
521 for line in markdown.lines() {
522 if !in_block {
523 if let Some(rest) = line.trim_start().strip_prefix("```") {
524 let tag = rest.trim();
525 in_block = true;
526 matches_info = tag == info;
527 current.clear();
528 }
529 continue;
530 }
531
532 if line.trim_start().starts_with("```") {
533 if matches_info {
534 blocks.push(current.clone());
535 }
536 in_block = false;
537 matches_info = false;
538 current.clear();
539 continue;
540 }
541
542 if matches_info {
543 current.push_str(line);
544 current.push('\n');
545 }
546 }
547
548 blocks
549}
550
551fn sha256_hex(bytes: &[u8]) -> String {
552 let mut hasher = Sha256::new();
553 hasher.update(bytes);
554 let digest = hasher.finalize();
555 hex::encode(digest)
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use tempfile::TempDir;
562
563 #[test]
564 fn extracts_single_run_spec_block() {
565 let md = r#"
566hello
567```yarli-run
568version = 1
569objective = "x"
570[tasks]
571items = [{ key = "a", cmd = "echo ok" }]
572```
573"#;
574 let blocks = extract_fenced_blocks(md, "yarli-run");
575 assert_eq!(blocks.len(), 1);
576 let spec: RunSpec = toml::from_str(&blocks[0]).unwrap();
577 assert_eq!(spec.version, 1);
578 assert_eq!(spec.tasks.items.len(), 1);
579 }
580
581 #[test]
582 fn include_expands_and_is_confined() {
583 let temp = TempDir::new().unwrap();
584 let root = temp.path();
585 fs::write(root.join("PROMPT.md"), "@include sub/plan.md\n").unwrap();
586 fs::create_dir_all(root.join("sub")).unwrap();
587 fs::write(
588 root.join("sub/plan.md"),
589 "```yarli-run\nversion = 1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"}]\n```\n",
590 )
591 .unwrap();
592
593 let loaded = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap();
594 assert!(loaded.expanded_text.contains("BEGIN_INCLUDE"));
595 assert_eq!(loaded.run_spec.tasks.items[0].key, "a");
596 assert_eq!(loaded.snapshot.included_files.len(), 2);
597 }
598
599 #[test]
600 fn optional_loader_accepts_plain_prompt_without_run_spec_block() {
601 let temp = TempDir::new().unwrap();
602 let root = temp.path();
603 fs::write(root.join("PROMPT.md"), "# plain prompt\nDo the work.\n").unwrap();
604
605 let loaded = load_prompt_with_optional_run_spec(&root.join("PROMPT.md")).unwrap();
606 assert!(loaded.run_spec.is_none());
607 assert!(loaded.expanded_text.contains("plain prompt"));
608 }
609
610 #[test]
611 fn strict_loader_still_rejects_missing_run_spec_block() {
612 let temp = TempDir::new().unwrap();
613 let root = temp.path();
614 fs::write(root.join("PROMPT.md"), "# plain prompt\nNo fenced block.\n").unwrap();
615
616 let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
617 assert!(err
618 .to_string()
619 .contains("must contain exactly one ```yarli-run fenced block (found 0)"));
620 }
621
622 #[test]
623 fn rejects_multiple_run_spec_blocks() {
624 let temp = TempDir::new().unwrap();
625 let root = temp.path();
626 fs::write(
627 root.join("PROMPT.md"),
628 "```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"}]\n```\n```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"b\",cmd=\"echo ok\"}]\n```\n",
629 )
630 .unwrap();
631 let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
632 assert!(err.to_string().contains("exactly one"));
633 }
634
635 #[test]
636 fn accepts_explicit_tranche_sequence() {
637 let md = r#"
638```yarli-run
639version = 1
640objective = "x"
641[tasks]
642items = [
643 { key = "a", cmd = "echo a" },
644 { key = "b", cmd = "echo b" },
645]
646[tranches]
647items = [
648 { key = "first", task_keys = ["a"] },
649 { key = "second", objective = "second pass", task_keys = ["b"] },
650]
651```
652"#;
653 let blocks = extract_fenced_blocks(md, "yarli-run");
654 let spec: RunSpec = toml::from_str(&blocks[0]).unwrap();
655 assert_eq!(spec.tranches.as_ref().unwrap().items.len(), 2);
656 }
657
658 #[test]
659 fn rejects_duplicate_tranche_task_keys() {
660 let temp = TempDir::new().unwrap();
661 let root = temp.path();
662 fs::write(
663 root.join("PROMPT.md"),
664 "```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"},{key=\"b\",cmd=\"echo ok\"}]\n[tranches]\nitems=[{key=\"t1\",task_keys=[\"a\"]},{key=\"t2\",task_keys=[\"a\",\"b\"]}]\n```\n",
665 )
666 .unwrap();
667 let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
668 assert!(err.to_string().contains("appears in multiple tranches"));
669 }
670
671 #[test]
672 fn parses_plan_guard_verify_only_mode() {
673 let md = r#"
674```yarli-run
675version = 1
676objective = "verification-only: CARD-R8-01"
677[tasks]
678items = [{ key = "test", cmd = "cargo test --workspace" }]
679[plan_guard]
680target = "CARD-R8-01"
681mode = "verify-only"
682```
683"#;
684 let blocks = extract_fenced_blocks(md, "yarli-run");
685 let spec: RunSpec = toml::from_str(&blocks[0]).unwrap();
686 let guard = spec.plan_guard.expect("plan_guard should parse");
687 assert_eq!(guard.target, "CARD-R8-01");
688 assert_eq!(guard.mode, RunSpecPlanGuardMode::VerifyOnly);
689 }
690
691 #[test]
692 fn rejects_empty_plan_guard_target() {
693 let temp = TempDir::new().unwrap();
694 let root = temp.path();
695 fs::write(
696 root.join("PROMPT.md"),
697 "```yarli-run\nversion=1\n[tasks]\nitems=[{key=\"a\",cmd=\"echo ok\"}]\n[plan_guard]\ntarget=\"\"\n```\n",
698 )
699 .unwrap();
700 let err = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap_err();
701 assert!(err.to_string().contains("plan_guard.target"));
702 }
703
704 #[test]
705 fn accepts_minimal_run_spec_without_tasks_for_config_first_mode() {
706 let temp = TempDir::new().unwrap();
707 let root = temp.path();
708 fs::write(
709 root.join("PROMPT.md"),
710 "```yarli-run\nversion=1\nobjective=\"implement plan\"\n```\n",
711 )
712 .unwrap();
713
714 let loaded = load_prompt_and_run_spec(&root.join("PROMPT.md")).unwrap();
715 assert_eq!(loaded.run_spec.version, 1);
716 assert!(loaded.run_spec.tasks.items.is_empty());
717 }
718}