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