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::Codex => {
619 argv.push("codex".to_owned());
620 if let Some(m) = &spec.model {
621 argv.push("-m".to_owned());
622 argv.push(m.clone());
623 }
624 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::Antigravity => {
632 argv.push("agy".to_owned());
633 argv.push("--add-dir".to_owned());
634 argv.push(widen.to_string_lossy().into_owned());
635 }
636 AgentKind::Command => {
637 if spec.command.is_empty() {
638 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
639 }
640 for raw in &spec.command {
643 argv.push(
644 raw.replace("{prompt_file}", &brief_path.to_string_lossy())
645 .replace("{cwd}", &repo.to_string_lossy()),
646 );
647 }
648 argv.extend(spec.extra_args.iter().cloned());
649 }
650 }
651 Ok(argv)
652}
653
654fn briefing(idea: Option<&str>, repo: &Path, out: &Path, language: &str) -> String {
656 let idea = match idea.map(str::trim).filter(|s| !s.is_empty()) {
657 Some(i) => i.to_owned(),
658 None => "The operator has not written the idea down yet. Ask them what \
659 they want to change, starting from the repository itself."
660 .to_owned(),
661 };
662 let lang = if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
667 String::new()
668 } else {
669 format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
670 };
671 format!(
672 "You are the planning leader for magi, which runs a blind \
673 multi-agent implementation competition: several agents will implement \
674 the task file you write, in isolated worktrees, unaware of each other, \
675 and judges will rank the results without knowing who wrote what.\n\n\
676 Your job is not to implement anything. It is to interview the operator \
677 until the change is pinned down, and then write one task file.\n\n\
678 # Repository\n\n{repo}\n\n\
679 Read it before you start asking. Questions that the code already \
680 answers spend the operator's patience for nothing.\n\n\
681 # The idea\n\n{idea}\n\n\
682 # How to run the interview\n\n\
683 - Ask about what you cannot determine yourself: intent, scope, which \
684 of several defensible designs the operator wants, what must not \
685 change.\n\
686 - Ask a few questions at a time and wait for the answers. Do not \
687 produce the task file after one exchange.\n\
688 - Disagree when you have grounds. A leader that agrees with everything \
689 adds nothing to what the operator already typed.\n\
690 - Confirm the plan in your own words and get an explicit yes before \
691 writing.\n\n\
692 # What to write, and where\n\n\
693 When the operator agrees the plan is right, write the task file to \
694 exactly this path:\n\n{out}\n\n\
695 Write that file and nothing else. Do not modify the repository: the \
696 competing agents do the implementation, and a repository you have \
697 already edited makes their diffs unjudgeable.\n\n\
698 magi will refuse a task file with no completion criteria, so those are \
699 not optional.\n\n\
700 # Task file specification\n\n{spec}\n\n\
701 When the file is written, tell the operator it is done and exit.{lang}",
702 repo = repo.display(),
703 out = out.display(),
704 spec = TASK_FILE_SPEC,
705 )
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 fn good_draft() -> String {
714 "# Report per-node durations in `magi show`\n\
715 \n\
716 ## Context\n\
717 \n\
718 `report::run` prints a run's nodes but not how long each took, so the \
719 numbers behind a slow competition have to be recovered from \
720 `run.json`'s `events` with `jq`.\n\
721 \n\
722 ## Change\n\
723 \n\
724 Add a duration column to the node table in `src/report.rs`, computed \
725 from the existing `events` timestamps in `RunState`.\n\
726 \n\
727 ## Constraints\n\
728 \n\
729 No new dependencies. Do not change `run.json`'s schema.\n\
730 \n\
731 ## Completion criteria\n\
732 \n\
733 - [ ] `magi show <id>` prints a duration for every finished node.\n\
734 - [ ] A node still running prints its elapsed time, not a blank.\n\
735 - [ ] `cargo test` passes.\n\
736 \n\
737 ## Out of scope\n\
738 \n\
739 The TUI's detail pane.\n"
740 .to_owned()
741 }
742
743 fn spec(id: &str, kind: AgentKind) -> AgentSpec {
744 AgentSpec {
745 id: id.to_owned(),
746 kind,
747 model: None,
748 command: Vec::new(),
749 extra_args: Vec::new(),
750 env: Default::default(),
751 prompt_delivery: None,
752 }
753 }
754
755 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
758 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
759 }
760
761 #[test]
762 fn a_realistic_task_file_is_accepted() {
763 let draft = good_draft();
764 assert!(
765 draft.len() >= MIN_DRAFT_BYTES,
766 "the fixture must be a real task file, not a stub"
767 );
768 assert_eq!(review_draft(&draft), Ok(()));
769 }
770
771 #[test]
772 fn a_bad_draft_reports_every_problem_at_once_rather_than_one_per_run() {
773 let problems = review_draft("###\n\n- - -\n").expect_err("must be rejected");
776 assert_eq!(problems.len(), 3, "{problems:?}");
777 assert_eq!(problems[0], NO_TITLE);
778 assert_eq!(problems[1], NO_CRITERIA);
779 assert_eq!(problems[2], SHORT_DRAFT);
780 }
781
782 #[test]
783 fn an_empty_draft_is_reported_as_empty_and_not_as_three_other_things() {
784 for body in ["", " \n\t\n "] {
785 let problems = review_draft(body).expect_err("must be rejected");
786 assert_eq!(problems, vec![EMPTY_DRAFT.to_owned()], "body {body:?}");
787 }
788 }
789
790 #[test]
791 fn a_draft_without_a_usable_title_is_rejected() {
792 let body = format!(
794 "#\n\n## Completion criteria\n\n- it works\n\n{}",
795 "x".repeat(300)
796 );
797 assert_eq!(
798 review_draft(&body).expect_err("must be rejected"),
799 vec![NO_TITLE.to_owned()]
800 );
801 }
802
803 #[test]
804 fn a_draft_without_completion_criteria_is_rejected_on_that_alone() {
805 let body = format!(
806 "# Rework the config loader\n\n## Change\n\nMake it layered.\n\n{}",
807 "prose. ".repeat(60)
808 );
809 assert!(body.len() >= MIN_DRAFT_BYTES);
810 assert_eq!(
811 review_draft(&body).expect_err("must be rejected"),
812 vec![NO_CRITERIA.to_owned()]
813 );
814 }
815
816 #[test]
817 fn completion_criteria_are_recognised_in_english_and_japanese_and_as_checkboxes() {
818 let filler = "x".repeat(300);
819 for section in [
820 "## Completion criteria\n\n- everything holds",
821 "## Acceptance\n\n- everything holds",
822 "### Acceptance criteria (all of them)\n\n- everything holds",
823 "**Completion criteria**\n\n- everything holds",
824 "## 完了条件\n\n- 全部そろっている",
825 "## 受け入れ基準\n\n- 全部そろっている",
826 "完了条件:\n\n- 全部そろっている",
827 "- [ ] no heading at all, just a checkbox",
828 ] {
829 let body = format!("# A real change\n\n{section}\n\n{filler}");
830 assert_eq!(
831 review_draft(&body),
832 Ok(()),
833 "must accept criteria written as {section:?}"
834 );
835 }
836 }
837
838 #[test]
839 fn prose_that_merely_mentions_acceptance_is_not_a_criteria_section() {
840 let body = format!(
841 "# A real change\n\nAcceptance of the design is up to you.\n\n{}",
842 "x".repeat(300)
843 );
844 assert_eq!(
845 review_draft(&body).expect_err("prose is not a section"),
846 vec![NO_CRITERIA.to_owned()]
847 );
848 }
849
850 #[test]
851 fn a_complete_but_tiny_draft_is_warned_about_and_not_refused() {
852 let body = "# Bump the poll interval to 5s\n\n## Completion criteria\n\n- [ ] it is 5s\n";
853 assert!(body.len() < MIN_DRAFT_BYTES);
854 let problems = review_draft(body).expect_err("must warn");
855 assert_eq!(problems, vec![SHORT_DRAFT.to_owned()]);
856
857 let dir = tempfile::tempdir().unwrap();
860 let path = dir.path().join("tiny.md");
861 std::fs::write(&path, body).unwrap();
862 let (read_back, warnings) = vet(&path).expect("length alone must not refuse");
863 assert_eq!(read_back, body);
864 assert_eq!(warnings, vec![SHORT_DRAFT.to_owned()]);
865 }
866
867 #[test]
870 fn a_refused_draft_is_still_on_disk_at_the_path_the_error_names() {
871 let dir = tempfile::tempdir().unwrap();
872 let path = dir.path().join("20260902-231501-ab12.md");
873 let body = "# Something the operator spent twenty minutes on\n\nBut with no criteria.\n";
874 std::fs::write(&path, body).unwrap();
875
876 let err = vet(&path).expect_err("no criteria must be refused");
877 let msg = err.to_string();
878 assert!(
879 msg.contains(&path.display().to_string()),
880 "the error must name the draft path: {msg}"
881 );
882 assert!(msg.contains("magi task add --file"), "{msg}");
883 assert_eq!(
884 std::fs::read_to_string(&path).expect("the draft must survive its refusal"),
885 body
886 );
887 }
888
889 #[test]
890 fn a_draft_lives_under_the_run_home_so_it_outlives_the_command_that_wrote_it() {
891 let dir = tempfile::tempdir().unwrap();
892 run::set_home(dir.path().to_path_buf());
893 assert_eq!(drafts_dir(), run::home().join("drafts"));
894 }
895
896 #[test]
897 fn a_missing_draft_is_reported_against_the_path_the_leader_was_given() {
898 let dir = tempfile::tempdir().unwrap();
899 let path = dir.path().join("never-written.md");
900 let msg = vet(&path).expect_err("nothing to file").to_string();
901 assert!(msg.contains(&path.display().to_string()), "{msg}");
902 }
903
904 #[test]
905 fn the_leader_is_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
906 let agents = [
907 spec("oc", AgentKind::Opencode),
908 spec("opus", AgentKind::Claude),
909 spec("agy", AgentKind::Antigravity),
910 ];
911 let got = pick(&agents, None, &without(&[])).expect("a leader");
912 assert_eq!(got.id, "opus");
913 }
914
915 #[test]
916 fn the_leader_falls_back_to_the_first_installed_agent_in_roster_order() {
917 let agents = [
918 spec("opus", AgentKind::Claude),
919 spec("oc", AgentKind::Opencode),
920 spec("agy", AgentKind::Antigravity),
921 ];
922 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a leader");
923 assert_eq!(got.id, "agy");
924 }
925
926 #[test]
927 fn an_empty_roster_says_what_to_install() {
928 let msg = pick(&[], None, &without(&[]))
929 .expect_err("nobody to plan with")
930 .to_string();
931 assert!(msg.contains("roster is empty"), "{msg}");
932 assert!(msg.contains("claude"), "{msg}");
933 assert!(msg.contains("magi.toml"), "{msg}");
934 }
935
936 #[test]
937 fn a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
938 let agents = [
939 spec("opus", AgentKind::Claude),
940 spec("oc", AgentKind::Opencode),
941 ];
942 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
943 let msg = format!("{err:#}");
944 assert!(msg.contains("claude"), "{msg}");
945 assert!(msg.contains("opencode"), "{msg}");
946 }
947
948 #[test]
949 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
950 let agents = [
951 spec("opus", AgentKind::Claude),
952 spec("oc", AgentKind::Opencode),
953 ];
954 let got = pick(&agents, Some("oc"), &without(&[])).expect("a leader");
955 assert_eq!(got.id, "oc");
956 }
957
958 #[test]
959 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
960 let agents = [
961 spec("opus", AgentKind::Claude),
962 spec("oc", AgentKind::Opencode),
963 ];
964 let msg = pick(&agents, Some("gemini"), &without(&[]))
965 .expect_err("no such agent")
966 .to_string();
967 assert!(msg.contains("gemini"), "{msg}");
968 assert!(msg.contains("opus, oc"), "{msg}");
969 }
970
971 #[test]
972 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
973 let agents = [
974 spec("opus", AgentKind::Claude),
975 spec("oc", AgentKind::Opencode),
976 ];
977 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
978 .expect_err("must not silently interview with another model")
979 .to_string();
980 assert!(msg.contains("opencode"), "{msg}");
981 assert!(msg.contains("--agent"), "{msg}");
982 }
983
984 #[test]
989 fn the_task_file_spec_asks_for_the_completion_criteria_the_validator_requires() {
990 assert!(TASK_FILE_SPEC.contains("## Completion criteria"));
991 assert!(has_completion_criteria(TASK_FILE_SPEC));
992 assert_eq!(
993 review_draft(TASK_FILE_SPEC),
994 Ok(()),
995 "the spec must pass the validator it is paired with"
996 );
997 }
998
999 #[test]
1000 fn the_briefing_carries_the_idea_the_repository_the_output_path_and_the_spec() {
1001 let b = briefing(
1002 Some("make the queue drain faster"),
1003 Path::new("/src/magi"),
1004 Path::new("/home/magi/drafts/x.md"),
1005 "en",
1006 );
1007 assert!(b.contains("make the queue drain faster"));
1008 assert!(b.contains("/src/magi"));
1009 assert!(b.contains("/home/magi/drafts/x.md"));
1010 assert!(b.contains("## Completion criteria"));
1011 assert!(
1012 !b.contains("Conduct the interview in"),
1013 "en adds no language line"
1014 );
1015 }
1016
1017 #[test]
1018 fn a_briefing_without_an_idea_tells_the_leader_to_start_the_conversation() {
1019 let b = briefing(
1020 Some(" "),
1021 Path::new("/src/magi"),
1022 Path::new("/o.md"),
1023 "ja",
1024 );
1025 assert!(b.contains("has not written the idea down yet"));
1026 assert!(b.contains("Conduct the interview in ja"));
1027 }
1028
1029 #[test]
1030 fn the_interactive_invocation_is_never_the_headless_one() {
1031 let brief = Path::new("/home/magi/drafts/x.briefing.md");
1032 let widen = Path::new("/home/magi/drafts");
1033 let repo = Path::new("/src/magi");
1034
1035 let mut claude = spec("opus", AgentKind::Claude);
1036 claude.model = Some("opus".to_owned());
1037 let argv = interactive_argv(&claude, brief, widen, repo).unwrap();
1039 assert_eq!(argv[0], "claude");
1040 assert!(!argv.iter().any(|a| a == "-p" || a == "--output-format"));
1041 assert!(!argv.iter().any(|a| a == "--permission-mode"));
1042 assert!(argv.windows(2).any(|w| w == ["--model", "opus"]));
1043 assert!(
1044 argv.windows(2)
1045 .any(|w| w == ["--add-dir", "/home/magi/drafts"])
1046 );
1047 assert!(
1048 argv.last().unwrap().contains(&brief.display().to_string()),
1049 "claude gets the briefing as its opening prompt: {argv:?}"
1050 );
1051
1052 assert_eq!(
1053 interactive_argv(&spec("oc", AgentKind::Opencode), brief, widen, repo).unwrap(),
1054 vec!["opencode".to_owned()],
1055 "opencode is entered plain, in the repository"
1056 );
1057 assert_eq!(
1058 interactive_argv(&spec("agy", AgentKind::Antigravity), brief, widen, repo).unwrap(),
1059 vec![
1060 "agy".to_owned(),
1061 "--add-dir".to_owned(),
1062 "/home/magi/drafts".to_owned()
1063 ]
1064 );
1065 }
1066
1067 #[test]
1068 fn a_command_agent_gets_its_own_argv_with_the_briefing_substituted_in() {
1069 let mut cmd = spec("local", AgentKind::Command);
1070 cmd.command = vec![
1071 "my-agent".to_owned(),
1072 "--brief".to_owned(),
1073 "{prompt_file}".to_owned(),
1074 "--in".to_owned(),
1075 "{cwd}".to_owned(),
1076 ];
1077 cmd.extra_args = vec!["--interactive".to_owned()];
1078 let argv = interactive_argv(
1079 &cmd,
1080 Path::new("/b.md"),
1081 Path::new("/drafts"),
1082 Path::new("/src/magi"),
1083 )
1084 .unwrap();
1085 assert_eq!(
1086 argv,
1087 vec![
1088 "my-agent",
1089 "--brief",
1090 "/b.md",
1091 "--in",
1092 "/src/magi",
1093 "--interactive"
1094 ]
1095 );
1096
1097 let empty = spec("broken", AgentKind::Command);
1098 let msg = interactive_argv(&empty, Path::new("/b.md"), Path::new("/d"), Path::new("/r"))
1099 .expect_err("a command agent with no command cannot be spawned")
1100 .to_string();
1101 assert!(msg.contains("broken"), "{msg}");
1102 }
1103 #[test]
1104 fn the_configured_planner_is_used_and_an_explicit_agent_still_beats_it() {
1105 let agents = [
1108 spec("opus", AgentKind::Claude),
1109 spec("oc", AgentKind::Opencode),
1110 spec("agy", AgentKind::Antigravity),
1111 ];
1112
1113 let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
1115 assert_eq!(by_config.id, "oc");
1116
1117 let by_default = pick(&agents, None, &without(&[])).expect("default");
1120 assert_eq!(by_default.kind, AgentKind::Claude);
1121
1122 let err = pick(&agents, Some("oc"), &without(&["oc"])).expect_err("not runnable");
1126 assert!(err.to_string().contains("oc"), "{err}");
1127 }
1128
1129 #[test]
1130 fn resolve_repo_uses_an_existing_directory_as_is() {
1131 let dir = tempfile::tempdir().unwrap();
1132 let resolved = resolve_repo(dir.path(), None).expect("an existing directory resolves");
1133 assert_eq!(resolved, dir.path());
1134 }
1135
1136 #[test]
1137 fn resolve_repo_resolves_a_short_name_against_configured_roots() {
1138 let tmp = tempfile::tempdir().unwrap();
1139 let root = tmp.path().join("root");
1140 let checkout = root.join("github.com").join("yukimemi").join("magi");
1141 std::fs::create_dir_all(checkout.join(".git")).unwrap();
1142
1143 let config_path = tmp.path().join("machine.toml");
1144 std::fs::write(
1145 &config_path,
1146 format!(
1147 "[repos]\nroots = [{:?}]\n",
1148 root.to_string_lossy().into_owned()
1149 ),
1150 )
1151 .unwrap();
1152
1153 let resolved =
1154 resolve_repo(Path::new("yukimemi/magi"), Some(&config_path)).expect("must resolve");
1155 assert_eq!(resolved, checkout.canonicalize().unwrap());
1156 }
1157
1158 #[test]
1159 fn resolve_repo_reports_an_unresolvable_short_name() {
1160 let tmp = tempfile::tempdir().unwrap();
1161 let config_path = tmp.path().join("machine.toml");
1162 std::fs::write(&config_path, "[repos]\nroots = []\n").unwrap();
1163
1164 let err = resolve_repo(Path::new("nope/nope"), Some(&config_path))
1165 .expect_err("nothing configured to match")
1166 .to_string();
1167 assert!(err.contains("nope/nope"), "{err}");
1168 }
1169
1170 #[test]
1171 fn from_background_is_none_when_no_chat_is_named() {
1172 let tmp = tempfile::tempdir().unwrap();
1173 let chats = chat::Chats::at(tmp.path().join("chats"));
1174 assert_eq!(from_background(&chats, None).unwrap(), None);
1175 }
1176
1177 #[test]
1178 fn from_background_names_the_missing_chat_id() {
1179 let tmp = tempfile::tempdir().unwrap();
1180 let chats = chat::Chats::at(tmp.path().join("chats"));
1181 let err = from_background(&chats, Some("nope"))
1182 .expect_err("no such chat")
1183 .to_string();
1184 assert!(err.contains("nope"), "{err}");
1185 }
1186}