1use std::io::{IsTerminal as _, Write as _};
42use std::path::{Path, PathBuf};
43use std::process::Stdio;
44
45use anyhow::{Context as _, Result, bail};
46
47use crate::chat;
48use crate::config::{AgentKind, AgentSpec, Config, which};
49use crate::queue::{self, Queue, Source, Task};
50use crate::repos;
51use crate::run;
52
53pub const TASK_FILE_SPEC: &str = "\
61The task file is markdown. magi hands it to every candidate verbatim and to
62every judge as the statement of what was asked, so it is the only thing any of
63them knows about the change. Use this shape:
64
65# <one line, imperative: what the change is>
66
67## Context
68
69Why this change, and what a competent stranger to this repository needs to know
70that the code does not say. Name the files, the modules and the symbols
71involved, with paths.
72
73## Change
74
75What to do, in enough mechanical detail that two candidates could not
76reasonably disagree about the target: the interfaces, the names, the shape of
77the data. Leave the *design* open - how it is built, in what order, with what
78internal structure. That gap is where blind judging does its work; closing it
79turns the competition into three transcriptions of the same answer.
80
81## Constraints
82
83Anything that must hold: files that must not be touched, dependencies that must
84not be added, conventions to follow, commands that must not be run.
85
86## Completion criteria
87
88- [ ] One observable, checkable statement per line.
89- [ ] Written so that a judge holding only the diff and this list can decide
90 whether each line holds. \"Works well\" cannot be judged; \"`magi plan`
91 exits non-zero and names the draft path when the draft has no completion
92 criteria\" can.
93
94## Out of scope
95
96What this competition must not touch, so that no candidate can win on breadth
97instead of on the change that was asked for.
98
99Rules for the task itself:
100
101- One change per competition. Bundling unrelated fixes makes the diff
102 unjudgeable and the statistics meaningless.
103- Nothing destructive or irreversible. Several candidates run unattended and in
104 parallel, and no node stops to ask.
105- Visual and UX judgement stays with the operator: no judge sees a rendered
106 screen, so do not ask for one to be evaluated.
107";
108
109const MIN_DRAFT_BYTES: usize = 200;
114
115pub const SHORT_DRAFT: &str = "the draft is under 200 bytes, which is about a \
124 title and one criterion: check the interview actually finished";
125
126const EMPTY_DRAFT: &str = "the draft is empty";
128
129const NO_TITLE: &str = "no line in the draft can be used as a title: the first \
131 non-blank line must say what the change is";
132
133const NO_CRITERIA: &str = "no completion criteria: add a `## Completion \
135 criteria` heading (or `## 完了条件`) with one checkable statement per line, \
136 or the candidates cannot be compared and the judges have nothing to \
137 measure against";
138
139const CRITERIA_HEADINGS: [&str; 4] = [
142 "completion criteria",
143 "acceptance",
144 "完了条件",
145 "受け入れ基準",
146];
147
148#[derive(Debug, Clone)]
150pub struct Opts {
151 pub idea: Option<String>,
154 pub repo: PathBuf,
156 pub config: Option<PathBuf>,
158 pub agent: Option<String>,
161 pub priority: i32,
163 pub yes: bool,
165 pub from: Option<String>,
172}
173
174impl Default for Opts {
175 fn default() -> Self {
176 Self {
177 idea: None,
178 repo: PathBuf::from("."),
179 config: None,
180 agent: None,
181 priority: 0,
182 yes: false,
183 from: None,
184 }
185 }
186}
187
188pub async fn plan(opts: Opts) -> Result<Task> {
194 if !std::io::stdin().is_terminal() {
198 bail!(
199 "`magi plan` is an interview and needs a terminal. To file a task \
200 without one, pipe it to `magi task add`."
201 );
202 }
203
204 let repo = resolve_repo(&opts.repo, opts.config.as_deref())?;
210 let repo = repo.canonicalize().unwrap_or(repo);
211 let (config, _sources) = Config::discover(&repo, opts.config.as_deref())?;
212 let want = opts.agent.as_deref().or(config.roles.planner.as_deref());
214 let leader = pick(&config.agents, want, &installed)?;
215
216 let background = from_background(&chat::Chats::open(), opts.from.as_deref())?;
217
218 let dir = drafts_dir();
219 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
220 let id = new_id();
221 let draft = dir.join(format!("{id}.md"));
222 let brief_path = dir.join(format!("{id}.briefing.md"));
223 let mut brief = briefing(opts.idea.as_deref(), &repo, &draft, &config.graph.language);
224 if let Some(background) = &background {
225 brief = format!("{background}\n\n{brief}");
229 }
230 std::fs::write(&brief_path, &brief)
231 .with_context(|| format!("write {}", brief_path.display()))?;
232
233 let argv = interactive_argv(&leader, &brief_path, &dir, &repo)?;
234
235 println!("leader: {}", leader.display());
240 println!("briefing: {}", brief_path.display());
241 println!("task file goes to: {}", draft.display());
242 println!("talk it through, then let the leader write the task file and exit.\n");
243
244 let mut cmd = tokio::process::Command::new(&argv[0]);
245 cmd.args(&argv[1..])
246 .current_dir(&repo)
247 .envs(&leader.env)
248 .stdin(Stdio::inherit())
252 .stdout(Stdio::inherit())
253 .stderr(Stdio::inherit());
254 let status = cmd
259 .status()
260 .await
261 .with_context(|| format!("spawn {} (is it installed?)", argv[0]))?;
262 if !status.success() {
263 eprintln!("note: {} exited with {status}", argv[0]);
268 }
269
270 let (body, warnings) = vet(&draft)?;
271 for w in &warnings {
272 eprintln!("warning: {w}");
273 }
274
275 let title = queue::title_from(&body, 72);
276 if !opts.yes {
277 println!("\n{title}");
278 println!("draft: {} ({} bytes)", draft.display(), body.len());
279 print!("file this task? [y/N] ");
280 std::io::stdout().flush().ok();
281 let mut answer = String::new();
282 std::io::stdin()
283 .read_line(&mut answer)
284 .context("read the confirmation")?;
285 if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
286 bail!(
287 "not filed. The draft is kept at {0} - file it later with \
288 `magi task add --file {0}`.",
289 draft.display()
290 );
291 }
292 }
293
294 let q = Queue::open();
295 let mut task = Task::new(title, body, repo, Source::Human);
296 task.priority = opts.priority;
297 q.put(&mut task)?;
298 println!("filed {} {}", task.short(), task.title);
299 Ok(task)
300}
301
302pub fn review_draft(body: &str) -> Result<(), Vec<String>> {
311 if body.trim().is_empty() {
315 return Err(vec![EMPTY_DRAFT.to_owned()]);
316 }
317
318 let mut problems = Vec::new();
319
320 if queue::title_from(body, 72) == "(empty task)" {
324 problems.push(NO_TITLE.to_owned());
325 }
326
327 if !has_completion_criteria(body) {
328 problems.push(NO_CRITERIA.to_owned());
329 }
330
331 if body.len() < MIN_DRAFT_BYTES {
332 problems.push(SHORT_DRAFT.to_owned());
333 }
334
335 if problems.is_empty() {
336 Ok(())
337 } else {
338 Err(problems)
339 }
340}
341
342fn vet(draft: &Path) -> Result<(String, Vec<String>)> {
348 let body = std::fs::read_to_string(draft).with_context(|| {
349 format!(
350 "no task file at {} - the leader was asked to write one there",
351 draft.display()
352 )
353 })?;
354 match review_draft(&body) {
355 Ok(()) => Ok((body, Vec::new())),
356 Err(problems) => {
357 let (soft, hard): (Vec<String>, Vec<String>) =
358 problems.into_iter().partition(|p| p == SHORT_DRAFT);
359 if hard.is_empty() {
360 return Ok((body, soft));
361 }
362 let list = hard
363 .iter()
364 .map(|p| format!(" - {p}"))
365 .collect::<Vec<_>>()
366 .join("\n");
367 bail!(
368 "the draft is not usable as a magi task:\n{list}\n\n\
369 It is kept at {0} - nothing was thrown away. Edit it and file \
370 it with `magi task add --file {0}`.",
371 draft.display()
372 );
373 }
374 }
375}
376
377fn has_completion_criteria(body: &str) -> bool {
387 body.lines().any(|line| {
388 let line = line.trim();
389 is_checkbox(line) || is_criteria_heading(line)
390 })
391}
392
393fn is_criteria_heading(line: &str) -> bool {
394 let decorated = line.starts_with(['#', '*', '_']);
395 let bare = line
396 .trim_start_matches(['#', '*', '_', '>', ' '])
397 .trim_end_matches(['#', '*', '_', ':', ':', ' '])
398 .trim()
399 .to_lowercase();
400 CRITERIA_HEADINGS.iter().any(|h| {
401 if decorated {
402 bare.starts_with(h)
403 } else {
404 bare == *h
405 }
406 })
407}
408
409fn is_checkbox(line: &str) -> bool {
410 let Some(rest) = line.strip_prefix(['-', '*', '+']) else {
411 return false;
412 };
413 let rest = rest.trim_start();
414 rest.starts_with("[ ]") || rest.starts_with("[x]") || rest.starts_with("[X]")
415}
416
417fn drafts_dir() -> PathBuf {
421 run::home().join("drafts")
422}
423
424fn new_id() -> String {
425 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
426 let seed = crate::rng::entropy();
427 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
428}
429
430fn resolve_repo(raw: &Path, explicit_config: Option<&Path>) -> Result<PathBuf> {
441 if raw.is_dir() {
442 return Ok(raw.to_owned());
443 }
444 let (cfg, _) = Config::discover(raw, explicit_config)?;
445 repos::resolve(&cfg.repos.roots, &raw.to_string_lossy())
446}
447
448fn from_background(chats: &chat::Chats, from: Option<&str>) -> Result<Option<String>> {
456 match from {
457 None => Ok(None),
458 Some(id) => Ok(Some(chat::derived_background(&chats.get(id)?))),
459 }
460}
461
462pub fn installed(spec: &AgentSpec) -> bool {
464 spec.kind.program().is_none_or(which)
467}
468
469pub fn pick(
492 agents: &[AgentSpec],
493 want: Option<&str>,
494 available: &dyn Fn(&AgentSpec) -> bool,
495) -> Result<AgentSpec> {
496 if let Some(id) = want {
497 let spec = agents
498 .iter()
499 .find(|a| a.id == id)
500 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
501 if !available(spec) {
502 bail!(
503 "agent `{}` needs `{}` on PATH; install it or pass a different \
504 --agent",
505 spec.id,
506 spec.kind.program().unwrap_or("its command")
507 );
508 }
509 return Ok(spec.clone());
510 }
511
512 if agents.is_empty() {
513 bail!(
514 "the agent roster is empty, so there is nobody to plan with: \
515 install one of claude, opencode or agy - magi derives a roster \
516 from what is on PATH - or add an [[agents]] entry to magi.toml."
517 );
518 }
519
520 if let Some(spec) = agents
521 .iter()
522 .find(|a| a.kind == AgentKind::Claude && available(a))
523 {
524 return Ok(spec.clone());
525 }
526
527 agents
528 .iter()
529 .find(|a| available(a))
530 .cloned()
531 .with_context(|| {
532 let missing = agents
533 .iter()
534 .filter_map(|a| a.kind.program())
535 .collect::<Vec<_>>()
536 .join(", ");
537 format!(
538 "no agent in the roster can be run here: install one of \
539 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
540 you do have"
541 )
542 })
543}
544
545fn ids(agents: &[AgentSpec]) -> String {
546 if agents.is_empty() {
547 return "no agents at all".to_owned();
548 }
549 agents
550 .iter()
551 .map(|a| a.id.clone())
552 .collect::<Vec<_>>()
553 .join(", ")
554}
555
556fn interactive_argv(
577 spec: &AgentSpec,
578 brief_path: &Path,
579 widen: &Path,
580 repo: &Path,
581) -> Result<Vec<String>> {
582 let mut argv: Vec<String> = Vec::new();
583 match spec.kind {
584 AgentKind::Claude => {
585 argv.push("claude".to_owned());
586 if let Some(m) = &spec.model {
587 argv.push("--model".to_owned());
588 argv.push(m.clone());
589 }
590 argv.push("--add-dir".to_owned());
595 argv.push(widen.to_string_lossy().into_owned());
596 argv.push(format!(
600 "Read the file at {} and follow it. Interview me about the \
601 change first; write the task file only once I say the plan is \
602 right.",
603 brief_path.display()
604 ));
605 }
606 AgentKind::Opencode => argv.push("opencode".to_owned()),
612 AgentKind::Antigravity => {
613 argv.push("agy".to_owned());
614 argv.push("--add-dir".to_owned());
615 argv.push(widen.to_string_lossy().into_owned());
616 }
617 AgentKind::Command => {
618 if spec.command.is_empty() {
619 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
620 }
621 for raw in &spec.command {
624 argv.push(
625 raw.replace("{prompt_file}", &brief_path.to_string_lossy())
626 .replace("{cwd}", &repo.to_string_lossy()),
627 );
628 }
629 argv.extend(spec.extra_args.iter().cloned());
630 }
631 }
632 Ok(argv)
633}
634
635fn briefing(idea: Option<&str>, repo: &Path, out: &Path, language: &str) -> String {
637 let idea = match idea.map(str::trim).filter(|s| !s.is_empty()) {
638 Some(i) => i.to_owned(),
639 None => "The operator has not written the idea down yet. Ask them what \
640 they want to change, starting from the repository itself."
641 .to_owned(),
642 };
643 let lang = if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
648 String::new()
649 } else {
650 format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
651 };
652 format!(
653 "You are the planning leader for magi, which runs a blind \
654 multi-agent implementation competition: several agents will implement \
655 the task file you write, in isolated worktrees, unaware of each other, \
656 and judges will rank the results without knowing who wrote what.\n\n\
657 Your job is not to implement anything. It is to interview the operator \
658 until the change is pinned down, and then write one task file.\n\n\
659 # Repository\n\n{repo}\n\n\
660 Read it before you start asking. Questions that the code already \
661 answers spend the operator's patience for nothing.\n\n\
662 # The idea\n\n{idea}\n\n\
663 # How to run the interview\n\n\
664 - Ask about what you cannot determine yourself: intent, scope, which \
665 of several defensible designs the operator wants, what must not \
666 change.\n\
667 - Ask a few questions at a time and wait for the answers. Do not \
668 produce the task file after one exchange.\n\
669 - Disagree when you have grounds. A leader that agrees with everything \
670 adds nothing to what the operator already typed.\n\
671 - Confirm the plan in your own words and get an explicit yes before \
672 writing.\n\n\
673 # What to write, and where\n\n\
674 When the operator agrees the plan is right, write the task file to \
675 exactly this path:\n\n{out}\n\n\
676 Write that file and nothing else. Do not modify the repository: the \
677 competing agents do the implementation, and a repository you have \
678 already edited makes their diffs unjudgeable.\n\n\
679 magi will refuse a task file with no completion criteria, so those are \
680 not optional.\n\n\
681 # Task file specification\n\n{spec}\n\n\
682 When the file is written, tell the operator it is done and exit.{lang}",
683 repo = repo.display(),
684 out = out.display(),
685 spec = TASK_FILE_SPEC,
686 )
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 fn good_draft() -> String {
695 "# Report per-node durations in `magi show`\n\
696 \n\
697 ## Context\n\
698 \n\
699 `report::run` prints a run's nodes but not how long each took, so the \
700 numbers behind a slow competition have to be recovered from \
701 `run.json`'s `events` with `jq`.\n\
702 \n\
703 ## Change\n\
704 \n\
705 Add a duration column to the node table in `src/report.rs`, computed \
706 from the existing `events` timestamps in `RunState`.\n\
707 \n\
708 ## Constraints\n\
709 \n\
710 No new dependencies. Do not change `run.json`'s schema.\n\
711 \n\
712 ## Completion criteria\n\
713 \n\
714 - [ ] `magi show <id>` prints a duration for every finished node.\n\
715 - [ ] A node still running prints its elapsed time, not a blank.\n\
716 - [ ] `cargo test` passes.\n\
717 \n\
718 ## Out of scope\n\
719 \n\
720 The TUI's detail pane.\n"
721 .to_owned()
722 }
723
724 fn spec(id: &str, kind: AgentKind) -> AgentSpec {
725 AgentSpec {
726 id: id.to_owned(),
727 kind,
728 model: None,
729 command: Vec::new(),
730 extra_args: Vec::new(),
731 env: Default::default(),
732 prompt_delivery: None,
733 }
734 }
735
736 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
739 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
740 }
741
742 #[test]
743 fn a_realistic_task_file_is_accepted() {
744 let draft = good_draft();
745 assert!(
746 draft.len() >= MIN_DRAFT_BYTES,
747 "the fixture must be a real task file, not a stub"
748 );
749 assert_eq!(review_draft(&draft), Ok(()));
750 }
751
752 #[test]
753 fn a_bad_draft_reports_every_problem_at_once_rather_than_one_per_run() {
754 let problems = review_draft("###\n\n- - -\n").expect_err("must be rejected");
757 assert_eq!(problems.len(), 3, "{problems:?}");
758 assert_eq!(problems[0], NO_TITLE);
759 assert_eq!(problems[1], NO_CRITERIA);
760 assert_eq!(problems[2], SHORT_DRAFT);
761 }
762
763 #[test]
764 fn an_empty_draft_is_reported_as_empty_and_not_as_three_other_things() {
765 for body in ["", " \n\t\n "] {
766 let problems = review_draft(body).expect_err("must be rejected");
767 assert_eq!(problems, vec![EMPTY_DRAFT.to_owned()], "body {body:?}");
768 }
769 }
770
771 #[test]
772 fn a_draft_without_a_usable_title_is_rejected() {
773 let body = format!(
775 "#\n\n## Completion criteria\n\n- it works\n\n{}",
776 "x".repeat(300)
777 );
778 assert_eq!(
779 review_draft(&body).expect_err("must be rejected"),
780 vec![NO_TITLE.to_owned()]
781 );
782 }
783
784 #[test]
785 fn a_draft_without_completion_criteria_is_rejected_on_that_alone() {
786 let body = format!(
787 "# Rework the config loader\n\n## Change\n\nMake it layered.\n\n{}",
788 "prose. ".repeat(60)
789 );
790 assert!(body.len() >= MIN_DRAFT_BYTES);
791 assert_eq!(
792 review_draft(&body).expect_err("must be rejected"),
793 vec![NO_CRITERIA.to_owned()]
794 );
795 }
796
797 #[test]
798 fn completion_criteria_are_recognised_in_english_and_japanese_and_as_checkboxes() {
799 let filler = "x".repeat(300);
800 for section in [
801 "## Completion criteria\n\n- everything holds",
802 "## Acceptance\n\n- everything holds",
803 "### Acceptance criteria (all of them)\n\n- everything holds",
804 "**Completion criteria**\n\n- everything holds",
805 "## 完了条件\n\n- 全部そろっている",
806 "## 受け入れ基準\n\n- 全部そろっている",
807 "完了条件:\n\n- 全部そろっている",
808 "- [ ] no heading at all, just a checkbox",
809 ] {
810 let body = format!("# A real change\n\n{section}\n\n{filler}");
811 assert_eq!(
812 review_draft(&body),
813 Ok(()),
814 "must accept criteria written as {section:?}"
815 );
816 }
817 }
818
819 #[test]
820 fn prose_that_merely_mentions_acceptance_is_not_a_criteria_section() {
821 let body = format!(
822 "# A real change\n\nAcceptance of the design is up to you.\n\n{}",
823 "x".repeat(300)
824 );
825 assert_eq!(
826 review_draft(&body).expect_err("prose is not a section"),
827 vec![NO_CRITERIA.to_owned()]
828 );
829 }
830
831 #[test]
832 fn a_complete_but_tiny_draft_is_warned_about_and_not_refused() {
833 let body = "# Bump the poll interval to 5s\n\n## Completion criteria\n\n- [ ] it is 5s\n";
834 assert!(body.len() < MIN_DRAFT_BYTES);
835 let problems = review_draft(body).expect_err("must warn");
836 assert_eq!(problems, vec![SHORT_DRAFT.to_owned()]);
837
838 let dir = tempfile::tempdir().unwrap();
841 let path = dir.path().join("tiny.md");
842 std::fs::write(&path, body).unwrap();
843 let (read_back, warnings) = vet(&path).expect("length alone must not refuse");
844 assert_eq!(read_back, body);
845 assert_eq!(warnings, vec![SHORT_DRAFT.to_owned()]);
846 }
847
848 #[test]
851 fn a_refused_draft_is_still_on_disk_at_the_path_the_error_names() {
852 let dir = tempfile::tempdir().unwrap();
853 let path = dir.path().join("20260902-231501-ab12.md");
854 let body = "# Something the operator spent twenty minutes on\n\nBut with no criteria.\n";
855 std::fs::write(&path, body).unwrap();
856
857 let err = vet(&path).expect_err("no criteria must be refused");
858 let msg = err.to_string();
859 assert!(
860 msg.contains(&path.display().to_string()),
861 "the error must name the draft path: {msg}"
862 );
863 assert!(msg.contains("magi task add --file"), "{msg}");
864 assert_eq!(
865 std::fs::read_to_string(&path).expect("the draft must survive its refusal"),
866 body
867 );
868 }
869
870 #[test]
871 fn a_draft_lives_under_the_run_home_so_it_outlives_the_command_that_wrote_it() {
872 assert_eq!(drafts_dir(), run::home().join("drafts"));
873 }
874
875 #[test]
876 fn a_missing_draft_is_reported_against_the_path_the_leader_was_given() {
877 let dir = tempfile::tempdir().unwrap();
878 let path = dir.path().join("never-written.md");
879 let msg = vet(&path).expect_err("nothing to file").to_string();
880 assert!(msg.contains(&path.display().to_string()), "{msg}");
881 }
882
883 #[test]
884 fn the_leader_is_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
885 let agents = [
886 spec("oc", AgentKind::Opencode),
887 spec("opus", AgentKind::Claude),
888 spec("agy", AgentKind::Antigravity),
889 ];
890 let got = pick(&agents, None, &without(&[])).expect("a leader");
891 assert_eq!(got.id, "opus");
892 }
893
894 #[test]
895 fn the_leader_falls_back_to_the_first_installed_agent_in_roster_order() {
896 let agents = [
897 spec("opus", AgentKind::Claude),
898 spec("oc", AgentKind::Opencode),
899 spec("agy", AgentKind::Antigravity),
900 ];
901 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a leader");
902 assert_eq!(got.id, "agy");
903 }
904
905 #[test]
906 fn an_empty_roster_says_what_to_install() {
907 let msg = pick(&[], None, &without(&[]))
908 .expect_err("nobody to plan with")
909 .to_string();
910 assert!(msg.contains("roster is empty"), "{msg}");
911 assert!(msg.contains("claude"), "{msg}");
912 assert!(msg.contains("magi.toml"), "{msg}");
913 }
914
915 #[test]
916 fn a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
917 let agents = [
918 spec("opus", AgentKind::Claude),
919 spec("oc", AgentKind::Opencode),
920 ];
921 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
922 let msg = format!("{err:#}");
923 assert!(msg.contains("claude"), "{msg}");
924 assert!(msg.contains("opencode"), "{msg}");
925 }
926
927 #[test]
928 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
929 let agents = [
930 spec("opus", AgentKind::Claude),
931 spec("oc", AgentKind::Opencode),
932 ];
933 let got = pick(&agents, Some("oc"), &without(&[])).expect("a leader");
934 assert_eq!(got.id, "oc");
935 }
936
937 #[test]
938 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
939 let agents = [
940 spec("opus", AgentKind::Claude),
941 spec("oc", AgentKind::Opencode),
942 ];
943 let msg = pick(&agents, Some("gemini"), &without(&[]))
944 .expect_err("no such agent")
945 .to_string();
946 assert!(msg.contains("gemini"), "{msg}");
947 assert!(msg.contains("opus, oc"), "{msg}");
948 }
949
950 #[test]
951 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
952 let agents = [
953 spec("opus", AgentKind::Claude),
954 spec("oc", AgentKind::Opencode),
955 ];
956 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
957 .expect_err("must not silently interview with another model")
958 .to_string();
959 assert!(msg.contains("opencode"), "{msg}");
960 assert!(msg.contains("--agent"), "{msg}");
961 }
962
963 #[test]
968 fn the_task_file_spec_asks_for_the_completion_criteria_the_validator_requires() {
969 assert!(TASK_FILE_SPEC.contains("## Completion criteria"));
970 assert!(has_completion_criteria(TASK_FILE_SPEC));
971 assert_eq!(
972 review_draft(TASK_FILE_SPEC),
973 Ok(()),
974 "the spec must pass the validator it is paired with"
975 );
976 }
977
978 #[test]
979 fn the_briefing_carries_the_idea_the_repository_the_output_path_and_the_spec() {
980 let b = briefing(
981 Some("make the queue drain faster"),
982 Path::new("/src/magi"),
983 Path::new("/home/magi/drafts/x.md"),
984 "en",
985 );
986 assert!(b.contains("make the queue drain faster"));
987 assert!(b.contains("/src/magi"));
988 assert!(b.contains("/home/magi/drafts/x.md"));
989 assert!(b.contains("## Completion criteria"));
990 assert!(
991 !b.contains("Conduct the interview in"),
992 "en adds no language line"
993 );
994 }
995
996 #[test]
997 fn a_briefing_without_an_idea_tells_the_leader_to_start_the_conversation() {
998 let b = briefing(
999 Some(" "),
1000 Path::new("/src/magi"),
1001 Path::new("/o.md"),
1002 "ja",
1003 );
1004 assert!(b.contains("has not written the idea down yet"));
1005 assert!(b.contains("Conduct the interview in ja"));
1006 }
1007
1008 #[test]
1009 fn the_interactive_invocation_is_never_the_headless_one() {
1010 let brief = Path::new("/home/magi/drafts/x.briefing.md");
1011 let widen = Path::new("/home/magi/drafts");
1012 let repo = Path::new("/src/magi");
1013
1014 let mut claude = spec("opus", AgentKind::Claude);
1015 claude.model = Some("opus".to_owned());
1016 let argv = interactive_argv(&claude, brief, widen, repo).unwrap();
1018 assert_eq!(argv[0], "claude");
1019 assert!(!argv.iter().any(|a| a == "-p" || a == "--output-format"));
1020 assert!(!argv.iter().any(|a| a == "--permission-mode"));
1021 assert!(argv.windows(2).any(|w| w == ["--model", "opus"]));
1022 assert!(
1023 argv.windows(2)
1024 .any(|w| w == ["--add-dir", "/home/magi/drafts"])
1025 );
1026 assert!(
1027 argv.last().unwrap().contains(&brief.display().to_string()),
1028 "claude gets the briefing as its opening prompt: {argv:?}"
1029 );
1030
1031 assert_eq!(
1032 interactive_argv(&spec("oc", AgentKind::Opencode), brief, widen, repo).unwrap(),
1033 vec!["opencode".to_owned()],
1034 "opencode is entered plain, in the repository"
1035 );
1036 assert_eq!(
1037 interactive_argv(&spec("agy", AgentKind::Antigravity), brief, widen, repo).unwrap(),
1038 vec![
1039 "agy".to_owned(),
1040 "--add-dir".to_owned(),
1041 "/home/magi/drafts".to_owned()
1042 ]
1043 );
1044 }
1045
1046 #[test]
1047 fn a_command_agent_gets_its_own_argv_with_the_briefing_substituted_in() {
1048 let mut cmd = spec("local", AgentKind::Command);
1049 cmd.command = vec![
1050 "my-agent".to_owned(),
1051 "--brief".to_owned(),
1052 "{prompt_file}".to_owned(),
1053 "--in".to_owned(),
1054 "{cwd}".to_owned(),
1055 ];
1056 cmd.extra_args = vec!["--interactive".to_owned()];
1057 let argv = interactive_argv(
1058 &cmd,
1059 Path::new("/b.md"),
1060 Path::new("/drafts"),
1061 Path::new("/src/magi"),
1062 )
1063 .unwrap();
1064 assert_eq!(
1065 argv,
1066 vec![
1067 "my-agent",
1068 "--brief",
1069 "/b.md",
1070 "--in",
1071 "/src/magi",
1072 "--interactive"
1073 ]
1074 );
1075
1076 let empty = spec("broken", AgentKind::Command);
1077 let msg = interactive_argv(&empty, Path::new("/b.md"), Path::new("/d"), Path::new("/r"))
1078 .expect_err("a command agent with no command cannot be spawned")
1079 .to_string();
1080 assert!(msg.contains("broken"), "{msg}");
1081 }
1082 #[test]
1083 fn the_configured_planner_is_used_and_an_explicit_agent_still_beats_it() {
1084 let agents = [
1087 spec("opus", AgentKind::Claude),
1088 spec("oc", AgentKind::Opencode),
1089 spec("agy", AgentKind::Antigravity),
1090 ];
1091
1092 let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
1094 assert_eq!(by_config.id, "oc");
1095
1096 let by_default = pick(&agents, None, &without(&[])).expect("default");
1099 assert_eq!(by_default.kind, AgentKind::Claude);
1100
1101 let err = pick(&agents, Some("oc"), &without(&["oc"])).expect_err("not runnable");
1105 assert!(err.to_string().contains("oc"), "{err}");
1106 }
1107
1108 #[test]
1109 fn resolve_repo_uses_an_existing_directory_as_is() {
1110 let dir = tempfile::tempdir().unwrap();
1111 let resolved = resolve_repo(dir.path(), None).expect("an existing directory resolves");
1112 assert_eq!(resolved, dir.path());
1113 }
1114
1115 #[test]
1116 fn resolve_repo_resolves_a_short_name_against_configured_roots() {
1117 let tmp = tempfile::tempdir().unwrap();
1118 let root = tmp.path().join("root");
1119 let checkout = root.join("github.com").join("yukimemi").join("magi");
1120 std::fs::create_dir_all(checkout.join(".git")).unwrap();
1121
1122 let config_path = tmp.path().join("machine.toml");
1123 std::fs::write(
1124 &config_path,
1125 format!(
1126 "[repos]\nroots = [{:?}]\n",
1127 root.to_string_lossy().into_owned()
1128 ),
1129 )
1130 .unwrap();
1131
1132 let resolved =
1133 resolve_repo(Path::new("yukimemi/magi"), Some(&config_path)).expect("must resolve");
1134 assert_eq!(resolved, checkout.canonicalize().unwrap());
1135 }
1136
1137 #[test]
1138 fn resolve_repo_reports_an_unresolvable_short_name() {
1139 let tmp = tempfile::tempdir().unwrap();
1140 let config_path = tmp.path().join("machine.toml");
1141 std::fs::write(&config_path, "[repos]\nroots = []\n").unwrap();
1142
1143 let err = resolve_repo(Path::new("nope/nope"), Some(&config_path))
1144 .expect_err("nothing configured to match")
1145 .to_string();
1146 assert!(err.contains("nope/nope"), "{err}");
1147 }
1148
1149 #[test]
1150 fn from_background_is_none_when_no_chat_is_named() {
1151 let tmp = tempfile::tempdir().unwrap();
1152 let chats = chat::Chats::at(tmp.path().join("chats"));
1153 assert_eq!(from_background(&chats, None).unwrap(), None);
1154 }
1155
1156 #[test]
1157 fn from_background_names_the_missing_chat_id() {
1158 let tmp = tempfile::tempdir().unwrap();
1159 let chats = chat::Chats::at(tmp.path().join("chats"));
1160 let err = from_background(&chats, Some("nope"))
1161 .expect_err("no such chat")
1162 .to_string();
1163 assert!(err.contains("nope"), "{err}");
1164 }
1165}