Skip to main content

magi/
plan.rs

1//! `magi plan`: the interview that turns an idea into a task file worth
2//! competing.
3//!
4//! A competition is only as good as its task statement. A vague task buys
5//! three vague candidates and a coin-toss tally, and the operator finds that
6//! out forty minutes and several dollars later. So this module sits in front of
7//! [`crate::queue`]: an agent interviews the operator about the idea, writes a
8//! full task file, and magi files that file rather than the one-liner the
9//! operator would otherwise have typed.
10//!
11//! # magi does not host the conversation
12//!
13//! There is no chat loop in here and there must not be one. The operator
14//! already has `claude`, `opencode` and `agy`, each with years of work in its
15//! own terminal UI - streaming, editing, file pickers, permission prompts. A
16//! conversation reimplemented over captured pipes would be worse than all
17//! three, and it is not what magi is for.
18//!
19//! What magi does instead is narrow and mechanical:
20//!
21//! 1. Write a *briefing* - the idea, the repository, and [`TASK_FILE_SPEC`] -
22//!    to a file, telling the agent to interview the operator and write its task
23//!    file to a named output path.
24//! 2. Spawn the agent with stdin, stdout and stderr **inherited**, so the
25//!    operator is talking to that CLI directly, in its own UI, with no magi in
26//!    the middle. Nothing is captured and there is no timeout: a human deciding
27//!    what to build takes as long as it takes.
28//! 3. When the agent exits, read the output path, check it with
29//!    [`review_draft`], and file it.
30//!
31//! # The draft is never thrown away
32//!
33//! The output path is under [`crate::run::home`]`/drafts` from the start, not a
34//! temporary file, and nothing in this module deletes it. A twenty-minute
35//! interview that ends in a validation failure must leave the operator holding
36//! the draft, named in the error message, so the fix is an edit and
37//! `magi task add --file` rather than a second interview. That is the single
38//! most important behaviour here, and [`vet`] is the only place that can break
39//! it.
40
41use 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
54/// The task-file shape the leader is asked to produce.
55///
56/// This is handed to the leader verbatim as part of its briefing, and it is
57/// also the document [`review_draft`] enforces. The two are checked against
58/// each other by a test, because a spec that asks for something the validator
59/// does not require - or worse, the reverse - turns a good interview into a
60/// rejected draft for no reason the operator can see.
61pub 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
110/// Shortest draft magi will treat as a finished task without comment.
111///
112/// Nothing magic about the number: it is roughly a title plus one criterion,
113/// and an interview that produced less than that almost always ended early.
114const MIN_DRAFT_BYTES: usize = 200;
115
116/// The problem [`review_draft`] reports for a draft that is merely suspiciously
117/// short.
118///
119/// It is a public constant because it is the *only* problem a caller may
120/// override - length alone is a smell, not a defect, and a genuinely small
121/// change deserves a small task file. Callers compare against this exact string
122/// to separate the warning from the refusals; [`plan`] does, and `magi task
123/// add` will when it starts vetting the files it is given.
124pub 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
127/// The problem reported for a draft with nothing in it at all.
128const EMPTY_DRAFT: &str = "the draft is empty";
129
130/// The problem reported for a draft with no line that could serve as a title.
131const 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
134/// The problem reported for a draft with no completion criteria.
135const 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
140/// Headings that mark a completion-criteria section, in the two languages this
141/// repository's operator writes tasks in.
142const CRITERIA_HEADINGS: [&str; 4] = [
143    "completion criteria",
144    "acceptance",
145    "完了条件",
146    "受け入れ基準",
147];
148
149/// What `magi plan` was asked to do.
150#[derive(Debug, Clone)]
151pub struct Opts {
152    /// The rough starting idea, if the operator gave one on the command line.
153    /// Absent is normal: the interview can start from nothing.
154    pub idea: Option<String>,
155    /// Repository the task will be competed in.
156    pub repo: PathBuf,
157    /// Explicit config file, as `--config`.
158    pub config: Option<PathBuf>,
159    /// Roster agent id to interview with. `None` picks one per the policy in
160    /// [`pick`].
161    pub agent: Option<String>,
162    /// Priority for the filed task.
163    pub priority: i32,
164    /// File the draft without the confirmation prompt.
165    pub yes: bool,
166    /// A browser-interview chat id (or unambiguous prefix/suffix) to
167    /// continue here. Its whole transcript and repository are folded into
168    /// this interview's opening briefing as background - see
169    /// [`chat::derived_background`]. `magi chat`, the CLI-side counterpart
170    /// that could name a terminal interview as `from`, does not exist yet
171    /// (issue #21), so only a browser chat can be named.
172    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
189/// Interview the operator, then file the resulting task.
190///
191/// Blocks for as long as the conversation lasts, holding the terminal. Returns
192/// the task that was filed; the draft it was filed from stays on disk either
193/// way.
194pub async fn plan(opts: Opts) -> Result<Task> {
195    // The whole command is a handover of the terminal to another program's UI.
196    // Without one there is nothing to hand over, and the operator would be left
197    // watching an agent wait for input that can never arrive.
198    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    // `--repo` accepts a path or a short `owner/repo` name; see
206    // `resolve_repo`. Absolute either way, because the daemon that eventually
207    // runs this task has its own working directory and a relative path -
208    // `.`, or a short name once resolved to one - would mean the wrong
209    // repository.
210    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    // `--agent` beats the config, the config beats the built-in order.
214    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        // Prepended, so the leader reads what it is inheriting before its own
227        // instructions - the order a human handing off a conversation would
228        // use.
229        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    // Say this before handing over the terminal. `opencode` and `agy` are
237    // entered plain (see `interactive_argv`), so for those two this line is the
238    // operator's only way to know where the briefing is if the agent comes up
239    // without having read it.
240    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        // Inherited, not piped: the operator is talking to this CLI's own UI.
251        // Capturing any of the three would replace that UI with magi's, which
252        // is the mistake this module exists to avoid.
253        .stdin(Stdio::inherit())
254        .stdout(Stdio::inherit())
255        .stderr(Stdio::inherit());
256    // No timeout, and no `kill_on_drop`. Every other agent invocation in magi
257    // is bounded because nothing is watching it; this one is bounded by a human
258    // who is sitting right there, and killing their conversation on a clock
259    // would lose the interview.
260    let status = cmd
261        .status()
262        .await
263        .with_context(|| format!("spawn {} (is it installed?)", argv[0]))?;
264    if !status.success() {
265        // Not fatal on its own: an agent that wrote the task file and then
266        // exited badly - or that the operator quit with Ctrl-C after it had
267        // written - still leaves something worth filing. Whether there is a
268        // draft is the question that matters, and `vet` answers it next.
269        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
304/// Is this draft usable as a magi task?
305///
306/// Separated from [`plan`] so that the rules are assertable without an
307/// interview, and so `magi task add` can reuse them for the files it is handed.
308///
309/// Every problem found is returned, not just the first: an operator about to
310/// edit a draft wants the whole list, and a validator that reveals one defect
311/// per run turns one fix into three.
312pub fn review_draft(body: &str) -> Result<(), Vec<String>> {
313    // An empty draft is reported as exactly one problem. It has no title and no
314    // criteria either, but saying so would be three ways of describing the same
315    // nothing, and the operator's next action is the same in all three cases.
316    if body.trim().is_empty() {
317        return Err(vec![EMPTY_DRAFT.to_owned()]);
318    }
319
320    let mut problems = Vec::new();
321
322    // Delegated rather than reimplemented: whatever `title_from` would accept
323    // is by definition a usable title, since it is what ends up on the task.
324    // Its placeholder is the queue's way of saying "there was nothing here".
325    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
344/// Read the draft, check it, and split the refusals from the warnings.
345///
346/// The draft file is read and never written, moved or removed, whatever the
347/// outcome - that is what makes a rejected interview recoverable, and the error
348/// names the path so the operator does not have to guess it.
349fn 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
379/// Does this draft state how anyone would know the task was done?
380///
381/// Two forms count: a heading naming the section, or a checkbox list anywhere.
382/// The heading match is deliberately lenient about decoration, because the same
383/// section arrives as `## Acceptance`, `**Acceptance criteria**` or `完了条件:`
384/// depending on which CLI wrote it, and rejecting a real criteria section over
385/// asterisks would teach the operator to distrust the check. An undecorated
386/// line has to *be* the phrase, though: prose that happens to contain the word
387/// "acceptance" is not a section.
388fn 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
419/// Where drafts live: under the run home, never in a temporary directory the OS
420/// may reap and never inside the repository, where it would show up as an
421/// untracked file in every candidate's worktree.
422fn 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
432/// Resolve `--repo` to a directory.
433///
434/// An existing directory is used as-is. Anything else is tried as a short
435/// name (`owner/repo`) against `[repos] roots` - which needs a config lookup
436/// of its own, since the roots have to be known before a name can be resolved
437/// against them. That lookup uses `raw` exactly as [`Config::discover`]
438/// itself would, so a short name that happens to also be a config layer's
439/// directory is not treated specially - it already failed the `is_dir` check
440/// above, so this is purely about finding `[repos] roots`, most often from
441/// the machine layer.
442fn 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
450/// The background block for `--from`, if given.
451///
452/// Split out of [`plan`] so the one mistake an operator can make with this
453/// flag - naming a chat that does not exist - is testable without a
454/// terminal. Reuses [`chat::derived_background`] rather than rendering the
455/// transcript a second way, so the terminal interview and the browser
456/// interview describe a derived conversation identically.
457fn 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
464/// Can this agent's CLI actually be run on this machine?
465pub fn installed(spec: &AgentSpec) -> bool {
466    // A `command` agent has no program of its own to look for - its argv is the
467    // operator's, and they are the authority on whether it runs.
468    spec.kind.program().is_none_or(which)
469}
470
471/// Choose the agent that will conduct the interview.
472///
473/// `available` is a parameter rather than a call to [`which`] so the order
474/// below is assertable on a machine with none of these CLIs installed, which is
475/// every CI runner.
476///
477/// The order, and why:
478///
479/// 1. An explicit `--agent` always wins, and is an error rather than a fallback
480///    when it is unusable. The operator naming a leader has a reason, and
481///    silently interviewing them with a different model would waste the
482///    conversation.
483/// 2. Otherwise a [`AgentKind::Claude`] seat, ahead of the roster order. It is
484///    the only one of the three CLIs magi can address before the first turn
485///    (see [`crate::agent`]'s session table), so it is the only one that can be
486///    handed the briefing as an argument and come up already knowing what the
487///    interview is for - with the others the operator has to point them at the
488///    briefing themselves. For the one command whose whole value is a smooth
489///    conversation, that difference decides it.
490/// 3. Otherwise the first runnable agent in roster order, because the roster
491///    order is the operator's own stated preference and magi has nothing better
492///    to go on.
493pub 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
558/// The argv that puts the operator in a conversation with `spec`.
559///
560/// Deliberately *not* [`crate::agent`]'s `build_command`: that one builds the
561/// headless invocation the graph needs - `claude -p`, `opencode run`,
562/// `agy --output-format json` - which prints one answer and exits, and would
563/// turn this interview into a single non-interactive turn. Every flag there
564/// exists to make a CLI machine-readable and unattended; every flag here exists
565/// to leave it exactly as interactive as the operator is used to.
566///
567/// Two other differences from the headless path are on purpose:
568///
569/// - No permission bypass. `bypassPermissions` / `--dangerously-skip-permissions`
570///   are how an unattended node gets work done with nobody to ask. Here there
571///   is somebody to ask, sitting at the terminal, and deciding on their behalf
572///   would be magi overstepping.
573/// - `spec.extra_args` is passed only for `kind = "command"`. For the three
574///   known CLIs those arguments were written for the headless invocation - an
575///   `--output-format json` or a `--print-timeout` among them ends the
576///   interview before it starts. For a `command` agent the whole argv is the
577///   operator's, so their arguments *are* the invocation.
578fn 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            // The briefing and the task file both live under the run home,
593            // outside the repository, so the workspace has to be widened to
594            // reach them - the same reason `agent::build_command` adds
595            // `--add-dir` for a file-delivered prompt.
596            argv.push("--add-dir".to_owned());
597            argv.push(widen.to_string_lossy().into_owned());
598            // The positional argument is claude's opening prompt, and the
599            // session stays interactive because `-p` is absent. This is the
600            // whole advantage that puts claude first in `pick`.
601            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        // Entered plain, in the repository. Neither CLI's interactive form has
609        // a documented way to be handed an opening prompt that magi can rely
610        // on, and guessing a flag would break the one command an operator
611        // cannot work around by editing a config file. They read the briefing
612        // because magi printed its path before handing over the terminal.
613        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            // Same placeholders as the headless path, so an operator's existing
624            // `command` agent works here without a second spelling to learn.
625            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
637/// What the leader is told before it starts talking.
638fn 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    // The interview is the operator talking, so their language matters more
646    // here than it does in any prompt the graph sends: an agent that answers a
647    // Japanese question in English makes the conversation slower for exactly
648    // the person magi is trying to help.
649    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    /// A task file of the shape `AGENTS.md` and [`TASK_FILE_SPEC`] describe.
696    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    /// Availability stub: an agent is runnable unless its id was listed as
739    /// missing. Keeps the selection tests off `PATH` entirely.
740    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        // Markdown decoration and nothing else: no usable title, no criteria,
757        // and far too short.
758        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        // Long enough, and it has criteria - the title is the only defect.
776        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        // And `vet` must let it through as a warning rather than a refusal,
841        // which is what makes `--yes` able to override length alone.
842        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    /// The behaviour a twenty-minute interview depends on: a draft magi refuses
851    /// is still there, byte for byte, at the path the refusal prints.
852    #[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    /// The spec and the validator are one contract in two places, and only this
966    /// test keeps them from drifting: a spec that stopped asking for completion
967    /// criteria would produce drafts magi refuses, with the operator following
968    /// magi's own instructions.
969    #[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        // Flags that would end the conversation, and the ones that carry it.
1019        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        // The interview is the one node a human sits through, and on a phone
1087        // there is no `--agent` to type - so it has to be settable in config.
1088        let agents = [
1089            spec("opus", AgentKind::Claude),
1090            spec("oc", AgentKind::Opencode),
1091            spec("agy", AgentKind::Antigravity),
1092        ];
1093
1094        // Config names the seat: roster order does not get a say.
1095        let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
1096        assert_eq!(by_config.id, "oc");
1097
1098        // Nothing named anywhere falls back to the built-in order, which
1099        // prefers a claude seat.
1100        let by_default = pick(&agents, None, &without(&[])).expect("default");
1101        assert_eq!(by_default.kind, AgentKind::Claude);
1102
1103        // A configured seat that is not installed is an error rather than a
1104        // silent substitution: an operator who named an interviewer had a
1105        // reason, and quietly using a different model wastes the conversation.
1106        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}