1use std::io::{IsTerminal as _, Write as _};
45use std::path::{Path, PathBuf};
46use std::process::Stdio;
47
48use anyhow::{Context as _, Result, bail};
49
50use crate::advise;
51use crate::chat;
52use crate::config::{AgentKind, AgentSpec, Config, which};
53use crate::proc::Quiet as _;
54use crate::queue::{self, Queue, Source, Task};
55use crate::repos;
56use crate::run;
57
58pub const TASK_FILE_SPEC: &str = "\
66The task file is markdown. magi hands it to every candidate verbatim and to
67every judge as the statement of what was asked, so it is the only thing any of
68them knows about the change. Use this shape:
69
70# <one line, imperative: what the change is>
71
72## Context
73
74Why this change, and what a competent stranger to this repository needs to know
75that the code does not say. Name the files, the modules and the symbols
76involved, with paths.
77
78## Change
79
80What to do, in enough mechanical detail that two candidates could not
81reasonably disagree about the target: the interfaces, the names, the shape of
82the data. Leave the *design* open - how it is built, in what order, with what
83internal structure. That gap is where blind judging does its work; closing it
84turns the competition into three transcriptions of the same answer.
85
86## Constraints
87
88Anything that must hold: files that must not be touched, dependencies that must
89not be added, conventions to follow, commands that must not be run.
90
91## Completion criteria
92
93- [ ] One observable, checkable statement per line.
94- [ ] Written so that a judge holding only the diff and this list can decide
95 whether each line holds. \"Works well\" cannot be judged; \"`magi plan`
96 exits non-zero and names the draft path when the draft has no completion
97 criteria\" can.
98
99## Out of scope
100
101What this competition must not touch, so that no candidate can win on breadth
102instead of on the change that was asked for.
103
104Rules for the task itself:
105
106- One change per competition. Bundling unrelated fixes makes the diff
107 unjudgeable and the statistics meaningless.
108- Nothing destructive or irreversible. Several candidates run unattended and in
109 parallel, and no node stops to ask.
110- Visual and UX judgement stays with the operator: no judge sees a rendered
111 screen, so do not ask for one to be evaluated.
112";
113
114const MIN_DRAFT_BYTES: usize = 200;
119
120pub const SHORT_DRAFT: &str = "the draft is under 200 bytes, which is about a \
129 title and one criterion: check the interview actually finished";
130
131const EMPTY_DRAFT: &str = "the draft is empty";
133
134const NO_TITLE: &str = "no line in the draft can be used as a title: the first \
136 non-blank line must say what the change is";
137
138const NO_CRITERIA: &str = "no completion criteria: add a `## Completion \
140 criteria` heading (or `## 完了条件`) with one checkable statement per line, \
141 or the candidates cannot be compared and the judges have nothing to \
142 measure against";
143
144const CRITERIA_HEADINGS: [&str; 4] = [
147 "completion criteria",
148 "acceptance",
149 "完了条件",
150 "受け入れ基準",
151];
152
153#[derive(Debug, Clone)]
155pub struct Opts {
156 pub idea: Option<String>,
159 pub repo: PathBuf,
161 pub config: Option<PathBuf>,
163 pub agent: Option<String>,
166 pub priority: i32,
168 pub yes: bool,
170 pub from: Option<String>,
177}
178
179impl Default for Opts {
180 fn default() -> Self {
181 Self {
182 idea: None,
183 repo: PathBuf::from("."),
184 config: None,
185 agent: None,
186 priority: 0,
187 yes: false,
188 from: None,
189 }
190 }
191}
192
193pub async fn plan(opts: Opts) -> Result<Task> {
199 if !std::io::stdin().is_terminal() {
203 bail!(
204 "`magi plan` is an interview and needs a terminal. To file a task \
205 without one, pipe it to `magi task add`."
206 );
207 }
208
209 let repo = resolve_repo(&opts.repo, opts.config.as_deref())?;
215 let repo = repo.canonicalize().unwrap_or(repo);
216 let (config, _sources) = Config::discover(&repo, opts.config.as_deref())?;
217 let want = opts.agent.as_deref().or(config.roles.planner.as_deref());
219 let leader = pick(&config.agents, want, &installed)?;
220
221 let background = from_background(&chat::Chats::open(), opts.from.as_deref())?;
222
223 let dir = drafts_dir();
224 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
225 let id = new_id();
226 let draft = dir.join(format!("{id}.md"));
227 let brief_path = dir.join(format!("{id}.briefing.md"));
228 let mut brief = briefing(opts.idea.as_deref(), &repo, &draft, &config.graph.language);
229 if let Some(background) = &background {
230 brief = format!("{background}\n\n{brief}");
234 }
235 std::fs::write(&brief_path, &brief)
236 .with_context(|| format!("write {}", brief_path.display()))?;
237
238 let argv = interactive_argv(&leader, &brief_path, &dir, &repo)?;
239
240 println!("leader: {}", leader.display());
245 println!("briefing: {}", brief_path.display());
246 println!("task file goes to: {}", draft.display());
247 println!("talk it through, then let the leader write the task file and exit.\n");
248
249 let mut cmd = tokio::process::Command::new(&argv[0]);
250 cmd.quiet();
251 cmd.args(&argv[1..])
252 .current_dir(&repo)
253 .envs(&leader.env)
254 .stdin(Stdio::inherit())
258 .stdout(Stdio::inherit())
259 .stderr(Stdio::inherit());
260 let status = cmd
265 .status()
266 .await
267 .with_context(|| format!("spawn {} (is it installed?)", argv[0]))?;
268 if !status.success() {
269 eprintln!("note: {} exited with {status}", argv[0]);
274 }
275
276 if config.graph.advise {
285 let advice = advise::run(&config, &repo, &draft, &dir, &id).await?;
286 println!(
287 "design deliberation: {} of {} advisor(s) produced a proposal; \
288 synthesis folded into {}",
289 advice.proposals().len(),
290 advice.records.len(),
291 draft.display()
292 );
293 }
294
295 let (body, warnings) = vet(&draft)?;
296 for w in &warnings {
297 eprintln!("warning: {w}");
298 }
299
300 let title = queue::title_from(&body, 72);
301 if !opts.yes {
302 println!("\n{title}");
303 println!("draft: {} ({} bytes)", draft.display(), body.len());
304 print!("file this task? [y/N] ");
305 std::io::stdout().flush().ok();
306 let mut answer = String::new();
307 std::io::stdin()
308 .read_line(&mut answer)
309 .context("read the confirmation")?;
310 if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
311 bail!(
312 "not filed. The draft is kept at {0} - file it later with \
313 `magi task add --file {0}`.",
314 draft.display()
315 );
316 }
317 }
318
319 let q = Queue::open();
320 let mut task = Task::new(title, body, repo, Source::Human);
321 task.priority = opts.priority;
322 q.put(&mut task)?;
323 println!("filed {} {}", task.short(), task.title);
324 Ok(task)
325}
326
327pub fn review_draft(body: &str) -> Result<(), Vec<String>> {
336 if body.trim().is_empty() {
340 return Err(vec![EMPTY_DRAFT.to_owned()]);
341 }
342
343 let mut problems = Vec::new();
344
345 if queue::title_from(body, 72) == "(empty task)" {
349 problems.push(NO_TITLE.to_owned());
350 }
351
352 if !has_completion_criteria(body) {
353 problems.push(NO_CRITERIA.to_owned());
354 }
355
356 if body.len() < MIN_DRAFT_BYTES {
357 problems.push(SHORT_DRAFT.to_owned());
358 }
359
360 if problems.is_empty() {
361 Ok(())
362 } else {
363 Err(problems)
364 }
365}
366
367fn vet(draft: &Path) -> Result<(String, Vec<String>)> {
373 let body = std::fs::read_to_string(draft).with_context(|| {
374 format!(
375 "no task file at {} - the leader was asked to write one there",
376 draft.display()
377 )
378 })?;
379 match review_draft(&body) {
380 Ok(()) => Ok((body, Vec::new())),
381 Err(problems) => {
382 let (soft, hard): (Vec<String>, Vec<String>) =
383 problems.into_iter().partition(|p| p == SHORT_DRAFT);
384 if hard.is_empty() {
385 return Ok((body, soft));
386 }
387 let list = hard
388 .iter()
389 .map(|p| format!(" - {p}"))
390 .collect::<Vec<_>>()
391 .join("\n");
392 bail!(
393 "the draft is not usable as a magi task:\n{list}\n\n\
394 It is kept at {0} - nothing was thrown away. Edit it and file \
395 it with `magi task add --file {0}`.",
396 draft.display()
397 );
398 }
399 }
400}
401
402fn has_completion_criteria(body: &str) -> bool {
412 body.lines().any(|line| {
413 let line = line.trim();
414 is_checkbox(line) || is_criteria_heading(line)
415 })
416}
417
418fn is_criteria_heading(line: &str) -> bool {
419 let decorated = line.starts_with(['#', '*', '_']);
420 let bare = line
421 .trim_start_matches(['#', '*', '_', '>', ' '])
422 .trim_end_matches(['#', '*', '_', ':', ':', ' '])
423 .trim()
424 .to_lowercase();
425 CRITERIA_HEADINGS.iter().any(|h| {
426 if decorated {
427 bare.starts_with(h)
428 } else {
429 bare == *h
430 }
431 })
432}
433
434fn is_checkbox(line: &str) -> bool {
435 let Some(rest) = line.strip_prefix(['-', '*', '+']) else {
436 return false;
437 };
438 let rest = rest.trim_start();
439 rest.starts_with("[ ]") || rest.starts_with("[x]") || rest.starts_with("[X]")
440}
441
442fn drafts_dir() -> PathBuf {
446 run::home().join("drafts")
447}
448
449fn new_id() -> String {
450 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
451 let seed = crate::rng::entropy();
452 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
453}
454
455fn resolve_repo(raw: &Path, explicit_config: Option<&Path>) -> Result<PathBuf> {
466 if raw.is_dir() {
467 return Ok(raw.to_owned());
468 }
469 let (cfg, _) = Config::discover(raw, explicit_config)?;
470 repos::resolve(&cfg.repos.roots, &raw.to_string_lossy())
471}
472
473fn from_background(chats: &chat::Chats, from: Option<&str>) -> Result<Option<String>> {
481 match from {
482 None => Ok(None),
483 Some(id) => Ok(Some(chat::derived_background(&chats.get(id)?))),
484 }
485}
486
487pub fn installed(spec: &AgentSpec) -> bool {
489 spec.kind.program().is_none_or(which)
492}
493
494pub fn pick(
517 agents: &[AgentSpec],
518 want: Option<&str>,
519 available: &dyn Fn(&AgentSpec) -> bool,
520) -> Result<AgentSpec> {
521 if let Some(id) = want {
522 let spec = agents
523 .iter()
524 .find(|a| a.id == id)
525 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
526 if !available(spec) {
527 bail!(
528 "agent `{}` needs `{}` on PATH; install it or pass a different \
529 --agent",
530 spec.id,
531 spec.kind.program().unwrap_or("its command")
532 );
533 }
534 return Ok(spec.clone());
535 }
536
537 if agents.is_empty() {
538 bail!(
539 "the agent roster is empty, so there is nobody to plan with: \
540 install one of claude, opencode or agy - magi derives a roster \
541 from what is on PATH - or add an [[agents]] entry to magi.toml."
542 );
543 }
544
545 if let Some(spec) = agents
546 .iter()
547 .find(|a| a.kind == AgentKind::Claude && available(a))
548 {
549 return Ok(spec.clone());
550 }
551
552 agents
553 .iter()
554 .find(|a| available(a))
555 .cloned()
556 .with_context(|| {
557 let missing = agents
558 .iter()
559 .filter_map(|a| a.kind.program())
560 .collect::<Vec<_>>()
561 .join(", ");
562 format!(
563 "no agent in the roster can be run here: install one of \
564 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
565 you do have"
566 )
567 })
568}
569
570fn ids(agents: &[AgentSpec]) -> String {
571 if agents.is_empty() {
572 return "no agents at all".to_owned();
573 }
574 agents
575 .iter()
576 .map(|a| a.id.clone())
577 .collect::<Vec<_>>()
578 .join(", ")
579}
580
581fn interactive_argv(
602 spec: &AgentSpec,
603 brief_path: &Path,
604 widen: &Path,
605 repo: &Path,
606) -> Result<Vec<String>> {
607 let mut argv: Vec<String> = Vec::new();
608 match spec.kind {
609 AgentKind::Claude => {
610 argv.push("claude".to_owned());
611 if let Some(m) = &spec.model {
612 argv.push("--model".to_owned());
613 argv.push(m.clone());
614 }
615 argv.push("--add-dir".to_owned());
620 argv.push(widen.to_string_lossy().into_owned());
621 argv.push(format!(
625 "Read the file at {} and follow it. Interview me about the \
626 change first; write the task file only once I say the plan is \
627 right.",
628 brief_path.display()
629 ));
630 }
631 AgentKind::Opencode => argv.push("opencode".to_owned()),
637 AgentKind::Codex => {
642 argv.push("codex".to_owned());
643 if let Some(m) = &spec.model {
644 argv.push("-m".to_owned());
645 argv.push(m.clone());
646 }
647 argv.push(format!(
648 "Read the file at {} and follow it. Interview me about the \
649 change first; write the task file only once I say the plan is \
650 right.",
651 brief_path.display()
652 ));
653 }
654 AgentKind::Antigravity => {
655 argv.push("agy".to_owned());
656 argv.push("--add-dir".to_owned());
657 argv.push(widen.to_string_lossy().into_owned());
658 }
659 AgentKind::Command => {
660 if spec.command.is_empty() {
661 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
662 }
663 for raw in &spec.command {
666 argv.push(
667 raw.replace("{prompt_file}", &brief_path.to_string_lossy())
668 .replace("{cwd}", &repo.to_string_lossy()),
669 );
670 }
671 argv.extend(spec.extra_args.iter().cloned());
672 }
673 }
674 Ok(argv)
675}
676
677fn briefing(idea: Option<&str>, repo: &Path, out: &Path, language: &str) -> String {
679 let idea = match idea.map(str::trim).filter(|s| !s.is_empty()) {
680 Some(i) => i.to_owned(),
681 None => "The operator has not written the idea down yet. Ask them what \
682 they want to change, starting from the repository itself."
683 .to_owned(),
684 };
685 let lang = if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
690 String::new()
691 } else {
692 format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
693 };
694 format!(
695 "You are the planning leader for magi, which runs a blind \
696 multi-agent implementation competition: several agents will implement \
697 the task file you write, in isolated worktrees, unaware of each other, \
698 and judges will rank the results without knowing who wrote what.\n\n\
699 Your job is not to implement anything. It is to interview the operator \
700 until the change is pinned down, and then write one task file.\n\n\
701 # Repository\n\n{repo}\n\n\
702 Read it before you start asking. Questions that the code already \
703 answers spend the operator's patience for nothing.\n\n\
704 # The idea\n\n{idea}\n\n\
705 # How to run the interview\n\n\
706 - Ask about what you cannot determine yourself: intent, scope, which \
707 of several defensible designs the operator wants, what must not \
708 change.\n\
709 - Ask a few questions at a time and wait for the answers. Do not \
710 produce the task file after one exchange.\n\
711 - Disagree when you have grounds. A leader that agrees with everything \
712 adds nothing to what the operator already typed.\n\
713 - Confirm the plan in your own words and get an explicit yes before \
714 writing.\n\n\
715 # What to write, and where\n\n\
716 When the operator agrees the plan is right, write the task file to \
717 exactly this path:\n\n{out}\n\n\
718 Write that file and nothing else. Do not modify the repository: the \
719 competing agents do the implementation, and a repository you have \
720 already edited makes their diffs unjudgeable.\n\n\
721 magi will refuse a task file with no completion criteria, so those are \
722 not optional.\n\n\
723 # Task file specification\n\n{spec}\n\n\
724 When the file is written, tell the operator it is done and exit.{lang}",
725 repo = repo.display(),
726 out = out.display(),
727 spec = TASK_FILE_SPEC,
728 )
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 fn good_draft() -> String {
737 "# Report per-node durations in `magi show`\n\
738 \n\
739 ## Context\n\
740 \n\
741 `report::run` prints a run's nodes but not how long each took, so the \
742 numbers behind a slow competition have to be recovered from \
743 `run.json`'s `events` with `jq`.\n\
744 \n\
745 ## Change\n\
746 \n\
747 Add a duration column to the node table in `src/report.rs`, computed \
748 from the existing `events` timestamps in `RunState`.\n\
749 \n\
750 ## Constraints\n\
751 \n\
752 No new dependencies. Do not change `run.json`'s schema.\n\
753 \n\
754 ## Completion criteria\n\
755 \n\
756 - [ ] `magi show <id>` prints a duration for every finished node.\n\
757 - [ ] A node still running prints its elapsed time, not a blank.\n\
758 - [ ] `cargo test` passes.\n\
759 \n\
760 ## Out of scope\n\
761 \n\
762 The TUI's detail pane.\n"
763 .to_owned()
764 }
765
766 fn spec(id: &str, kind: AgentKind) -> AgentSpec {
767 AgentSpec {
768 id: id.to_owned(),
769 kind,
770 model: None,
771 command: Vec::new(),
772 extra_args: Vec::new(),
773 env: Default::default(),
774 prompt_delivery: None,
775 }
776 }
777
778 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
781 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
782 }
783
784 #[test]
785 fn a_realistic_task_file_is_accepted() {
786 let draft = good_draft();
787 assert!(
788 draft.len() >= MIN_DRAFT_BYTES,
789 "the fixture must be a real task file, not a stub"
790 );
791 assert_eq!(review_draft(&draft), Ok(()));
792 }
793
794 #[test]
795 fn a_bad_draft_reports_every_problem_at_once_rather_than_one_per_run() {
796 let problems = review_draft("###\n\n- - -\n").expect_err("must be rejected");
799 assert_eq!(problems.len(), 3, "{problems:?}");
800 assert_eq!(problems[0], NO_TITLE);
801 assert_eq!(problems[1], NO_CRITERIA);
802 assert_eq!(problems[2], SHORT_DRAFT);
803 }
804
805 #[test]
806 fn an_empty_draft_is_reported_as_empty_and_not_as_three_other_things() {
807 for body in ["", " \n\t\n "] {
808 let problems = review_draft(body).expect_err("must be rejected");
809 assert_eq!(problems, vec![EMPTY_DRAFT.to_owned()], "body {body:?}");
810 }
811 }
812
813 #[test]
814 fn a_draft_without_a_usable_title_is_rejected() {
815 let body = format!(
817 "#\n\n## Completion criteria\n\n- it works\n\n{}",
818 "x".repeat(300)
819 );
820 assert_eq!(
821 review_draft(&body).expect_err("must be rejected"),
822 vec![NO_TITLE.to_owned()]
823 );
824 }
825
826 #[test]
827 fn a_draft_without_completion_criteria_is_rejected_on_that_alone() {
828 let body = format!(
829 "# Rework the config loader\n\n## Change\n\nMake it layered.\n\n{}",
830 "prose. ".repeat(60)
831 );
832 assert!(body.len() >= MIN_DRAFT_BYTES);
833 assert_eq!(
834 review_draft(&body).expect_err("must be rejected"),
835 vec![NO_CRITERIA.to_owned()]
836 );
837 }
838
839 #[test]
840 fn completion_criteria_are_recognised_in_english_and_japanese_and_as_checkboxes() {
841 let filler = "x".repeat(300);
842 for section in [
843 "## Completion criteria\n\n- everything holds",
844 "## Acceptance\n\n- everything holds",
845 "### Acceptance criteria (all of them)\n\n- everything holds",
846 "**Completion criteria**\n\n- everything holds",
847 "## 完了条件\n\n- 全部そろっている",
848 "## 受け入れ基準\n\n- 全部そろっている",
849 "完了条件:\n\n- 全部そろっている",
850 "- [ ] no heading at all, just a checkbox",
851 ] {
852 let body = format!("# A real change\n\n{section}\n\n{filler}");
853 assert_eq!(
854 review_draft(&body),
855 Ok(()),
856 "must accept criteria written as {section:?}"
857 );
858 }
859 }
860
861 #[test]
862 fn prose_that_merely_mentions_acceptance_is_not_a_criteria_section() {
863 let body = format!(
864 "# A real change\n\nAcceptance of the design is up to you.\n\n{}",
865 "x".repeat(300)
866 );
867 assert_eq!(
868 review_draft(&body).expect_err("prose is not a section"),
869 vec![NO_CRITERIA.to_owned()]
870 );
871 }
872
873 #[test]
874 fn a_complete_but_tiny_draft_is_warned_about_and_not_refused() {
875 let body = "# Bump the poll interval to 5s\n\n## Completion criteria\n\n- [ ] it is 5s\n";
876 assert!(body.len() < MIN_DRAFT_BYTES);
877 let problems = review_draft(body).expect_err("must warn");
878 assert_eq!(problems, vec![SHORT_DRAFT.to_owned()]);
879
880 let dir = tempfile::tempdir().unwrap();
883 let path = dir.path().join("tiny.md");
884 std::fs::write(&path, body).unwrap();
885 let (read_back, warnings) = vet(&path).expect("length alone must not refuse");
886 assert_eq!(read_back, body);
887 assert_eq!(warnings, vec![SHORT_DRAFT.to_owned()]);
888 }
889
890 #[test]
893 fn a_refused_draft_is_still_on_disk_at_the_path_the_error_names() {
894 let dir = tempfile::tempdir().unwrap();
895 let path = dir.path().join("20260902-231501-ab12.md");
896 let body = "# Something the operator spent twenty minutes on\n\nBut with no criteria.\n";
897 std::fs::write(&path, body).unwrap();
898
899 let err = vet(&path).expect_err("no criteria must be refused");
900 let msg = err.to_string();
901 assert!(
902 msg.contains(&path.display().to_string()),
903 "the error must name the draft path: {msg}"
904 );
905 assert!(msg.contains("magi task add --file"), "{msg}");
906 assert_eq!(
907 std::fs::read_to_string(&path).expect("the draft must survive its refusal"),
908 body
909 );
910 }
911
912 #[test]
913 fn a_draft_lives_under_the_run_home_so_it_outlives_the_command_that_wrote_it() {
914 let dir = tempfile::tempdir().unwrap();
915 run::set_home(dir.path().to_path_buf());
916 assert_eq!(drafts_dir(), run::home().join("drafts"));
917 }
918
919 #[test]
920 fn a_missing_draft_is_reported_against_the_path_the_leader_was_given() {
921 let dir = tempfile::tempdir().unwrap();
922 let path = dir.path().join("never-written.md");
923 let msg = vet(&path).expect_err("nothing to file").to_string();
924 assert!(msg.contains(&path.display().to_string()), "{msg}");
925 }
926
927 #[test]
928 fn the_leader_is_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
929 let agents = [
930 spec("oc", AgentKind::Opencode),
931 spec("opus", AgentKind::Claude),
932 spec("agy", AgentKind::Antigravity),
933 ];
934 let got = pick(&agents, None, &without(&[])).expect("a leader");
935 assert_eq!(got.id, "opus");
936 }
937
938 #[test]
939 fn the_leader_falls_back_to_the_first_installed_agent_in_roster_order() {
940 let agents = [
941 spec("opus", AgentKind::Claude),
942 spec("oc", AgentKind::Opencode),
943 spec("agy", AgentKind::Antigravity),
944 ];
945 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a leader");
946 assert_eq!(got.id, "agy");
947 }
948
949 #[test]
950 fn an_empty_roster_says_what_to_install() {
951 let msg = pick(&[], None, &without(&[]))
952 .expect_err("nobody to plan with")
953 .to_string();
954 assert!(msg.contains("roster is empty"), "{msg}");
955 assert!(msg.contains("claude"), "{msg}");
956 assert!(msg.contains("magi.toml"), "{msg}");
957 }
958
959 #[test]
960 fn a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
961 let agents = [
962 spec("opus", AgentKind::Claude),
963 spec("oc", AgentKind::Opencode),
964 ];
965 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
966 let msg = format!("{err:#}");
967 assert!(msg.contains("claude"), "{msg}");
968 assert!(msg.contains("opencode"), "{msg}");
969 }
970
971 #[test]
972 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
973 let agents = [
974 spec("opus", AgentKind::Claude),
975 spec("oc", AgentKind::Opencode),
976 ];
977 let got = pick(&agents, Some("oc"), &without(&[])).expect("a leader");
978 assert_eq!(got.id, "oc");
979 }
980
981 #[test]
982 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
983 let agents = [
984 spec("opus", AgentKind::Claude),
985 spec("oc", AgentKind::Opencode),
986 ];
987 let msg = pick(&agents, Some("gemini"), &without(&[]))
988 .expect_err("no such agent")
989 .to_string();
990 assert!(msg.contains("gemini"), "{msg}");
991 assert!(msg.contains("opus, oc"), "{msg}");
992 }
993
994 #[test]
995 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
996 let agents = [
997 spec("opus", AgentKind::Claude),
998 spec("oc", AgentKind::Opencode),
999 ];
1000 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
1001 .expect_err("must not silently interview with another model")
1002 .to_string();
1003 assert!(msg.contains("opencode"), "{msg}");
1004 assert!(msg.contains("--agent"), "{msg}");
1005 }
1006
1007 #[test]
1012 fn the_task_file_spec_asks_for_the_completion_criteria_the_validator_requires() {
1013 assert!(TASK_FILE_SPEC.contains("## Completion criteria"));
1014 assert!(has_completion_criteria(TASK_FILE_SPEC));
1015 assert_eq!(
1016 review_draft(TASK_FILE_SPEC),
1017 Ok(()),
1018 "the spec must pass the validator it is paired with"
1019 );
1020 }
1021
1022 #[test]
1023 fn the_briefing_carries_the_idea_the_repository_the_output_path_and_the_spec() {
1024 let b = briefing(
1025 Some("make the queue drain faster"),
1026 Path::new("/src/magi"),
1027 Path::new("/home/magi/drafts/x.md"),
1028 "en",
1029 );
1030 assert!(b.contains("make the queue drain faster"));
1031 assert!(b.contains("/src/magi"));
1032 assert!(b.contains("/home/magi/drafts/x.md"));
1033 assert!(b.contains("## Completion criteria"));
1034 assert!(
1035 !b.contains("Conduct the interview in"),
1036 "en adds no language line"
1037 );
1038 }
1039
1040 #[test]
1041 fn a_briefing_without_an_idea_tells_the_leader_to_start_the_conversation() {
1042 let b = briefing(
1043 Some(" "),
1044 Path::new("/src/magi"),
1045 Path::new("/o.md"),
1046 "ja",
1047 );
1048 assert!(b.contains("has not written the idea down yet"));
1049 assert!(b.contains("Conduct the interview in ja"));
1050 }
1051
1052 #[test]
1053 fn the_interactive_invocation_is_never_the_headless_one() {
1054 let brief = Path::new("/home/magi/drafts/x.briefing.md");
1055 let widen = Path::new("/home/magi/drafts");
1056 let repo = Path::new("/src/magi");
1057
1058 let mut claude = spec("opus", AgentKind::Claude);
1059 claude.model = Some("opus".to_owned());
1060 let argv = interactive_argv(&claude, brief, widen, repo).unwrap();
1062 assert_eq!(argv[0], "claude");
1063 assert!(!argv.iter().any(|a| a == "-p" || a == "--output-format"));
1064 assert!(!argv.iter().any(|a| a == "--permission-mode"));
1065 assert!(argv.windows(2).any(|w| w == ["--model", "opus"]));
1066 assert!(
1067 argv.windows(2)
1068 .any(|w| w == ["--add-dir", "/home/magi/drafts"])
1069 );
1070 assert!(
1071 argv.last().unwrap().contains(&brief.display().to_string()),
1072 "claude gets the briefing as its opening prompt: {argv:?}"
1073 );
1074
1075 assert_eq!(
1076 interactive_argv(&spec("oc", AgentKind::Opencode), brief, widen, repo).unwrap(),
1077 vec!["opencode".to_owned()],
1078 "opencode is entered plain, in the repository"
1079 );
1080 assert_eq!(
1081 interactive_argv(&spec("agy", AgentKind::Antigravity), brief, widen, repo).unwrap(),
1082 vec![
1083 "agy".to_owned(),
1084 "--add-dir".to_owned(),
1085 "/home/magi/drafts".to_owned()
1086 ]
1087 );
1088 }
1089
1090 #[test]
1091 fn a_command_agent_gets_its_own_argv_with_the_briefing_substituted_in() {
1092 let mut cmd = spec("local", AgentKind::Command);
1093 cmd.command = vec![
1094 "my-agent".to_owned(),
1095 "--brief".to_owned(),
1096 "{prompt_file}".to_owned(),
1097 "--in".to_owned(),
1098 "{cwd}".to_owned(),
1099 ];
1100 cmd.extra_args = vec!["--interactive".to_owned()];
1101 let argv = interactive_argv(
1102 &cmd,
1103 Path::new("/b.md"),
1104 Path::new("/drafts"),
1105 Path::new("/src/magi"),
1106 )
1107 .unwrap();
1108 assert_eq!(
1109 argv,
1110 vec![
1111 "my-agent",
1112 "--brief",
1113 "/b.md",
1114 "--in",
1115 "/src/magi",
1116 "--interactive"
1117 ]
1118 );
1119
1120 let empty = spec("broken", AgentKind::Command);
1121 let msg = interactive_argv(&empty, Path::new("/b.md"), Path::new("/d"), Path::new("/r"))
1122 .expect_err("a command agent with no command cannot be spawned")
1123 .to_string();
1124 assert!(msg.contains("broken"), "{msg}");
1125 }
1126 #[test]
1127 fn the_configured_planner_is_used_and_an_explicit_agent_still_beats_it() {
1128 let agents = [
1131 spec("opus", AgentKind::Claude),
1132 spec("oc", AgentKind::Opencode),
1133 spec("agy", AgentKind::Antigravity),
1134 ];
1135
1136 let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
1138 assert_eq!(by_config.id, "oc");
1139
1140 let by_default = pick(&agents, None, &without(&[])).expect("default");
1143 assert_eq!(by_default.kind, AgentKind::Claude);
1144
1145 let err = pick(&agents, Some("oc"), &without(&["oc"])).expect_err("not runnable");
1149 assert!(err.to_string().contains("oc"), "{err}");
1150 }
1151
1152 #[test]
1153 fn resolve_repo_uses_an_existing_directory_as_is() {
1154 let dir = tempfile::tempdir().unwrap();
1155 let resolved = resolve_repo(dir.path(), None).expect("an existing directory resolves");
1156 assert_eq!(resolved, dir.path());
1157 }
1158
1159 #[test]
1160 fn resolve_repo_resolves_a_short_name_against_configured_roots() {
1161 let tmp = tempfile::tempdir().unwrap();
1162 let root = tmp.path().join("root");
1163 let checkout = root.join("github.com").join("yukimemi").join("magi");
1164 std::fs::create_dir_all(checkout.join(".git")).unwrap();
1165
1166 let config_path = tmp.path().join("machine.toml");
1167 std::fs::write(
1168 &config_path,
1169 format!(
1170 "[repos]\nroots = [{:?}]\n",
1171 root.to_string_lossy().into_owned()
1172 ),
1173 )
1174 .unwrap();
1175
1176 let resolved =
1177 resolve_repo(Path::new("yukimemi/magi"), Some(&config_path)).expect("must resolve");
1178 assert_eq!(resolved, checkout.canonicalize().unwrap());
1179 }
1180
1181 #[test]
1182 fn resolve_repo_reports_an_unresolvable_short_name() {
1183 let tmp = tempfile::tempdir().unwrap();
1184 let config_path = tmp.path().join("machine.toml");
1185 std::fs::write(&config_path, "[repos]\nroots = []\n").unwrap();
1186
1187 let err = resolve_repo(Path::new("nope/nope"), Some(&config_path))
1188 .expect_err("nothing configured to match")
1189 .to_string();
1190 assert!(err.contains("nope/nope"), "{err}");
1191 }
1192
1193 #[test]
1194 fn from_background_is_none_when_no_chat_is_named() {
1195 let tmp = tempfile::tempdir().unwrap();
1196 let chats = chat::Chats::at(tmp.path().join("chats"));
1197 assert_eq!(from_background(&chats, None).unwrap(), None);
1198 }
1199
1200 #[test]
1201 fn from_background_names_the_missing_chat_id() {
1202 let tmp = tempfile::tempdir().unwrap();
1203 let chats = chat::Chats::at(tmp.path().join("chats"));
1204 let err = from_background(&chats, Some("nope"))
1205 .expect_err("no such chat")
1206 .to_string();
1207 assert!(err.contains("nope"), "{err}");
1208 }
1209}