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        // Codex's interactive form takes an opening prompt positionally, and
615        // it needs no widening: `codex` reads any absolute path the sandbox
616        // allows, and the interview is read-only until the operator agrees a
617        // task file should be written.
618        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            // Same placeholders as the headless path, so an operator's existing
641            // `command` agent works here without a second spelling to learn.
642            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
654/// What the leader is told before it starts talking.
655fn 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    // The interview is the operator talking, so their language matters more
663    // here than it does in any prompt the graph sends: an agent that answers a
664    // Japanese question in English makes the conversation slower for exactly
665    // the person magi is trying to help.
666    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    /// A task file of the shape `AGENTS.md` and [`TASK_FILE_SPEC`] describe.
713    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    /// Availability stub: an agent is runnable unless its id was listed as
756    /// missing. Keeps the selection tests off `PATH` entirely.
757    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        // Markdown decoration and nothing else: no usable title, no criteria,
774        // and far too short.
775        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        // Long enough, and it has criteria - the title is the only defect.
793        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        // And `vet` must let it through as a warning rather than a refusal,
858        // which is what makes `--yes` able to override length alone.
859        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    /// The behaviour a twenty-minute interview depends on: a draft magi refuses
868    /// is still there, byte for byte, at the path the refusal prints.
869    #[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    /// The spec and the validator are one contract in two places, and only this
985    /// test keeps them from drifting: a spec that stopped asking for completion
986    /// criteria would produce drafts magi refuses, with the operator following
987    /// magi's own instructions.
988    #[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        // Flags that would end the conversation, and the ones that carry it.
1038        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        // The interview is the one node a human sits through, and on a phone
1106        // there is no `--agent` to type - so it has to be settable in config.
1107        let agents = [
1108            spec("opus", AgentKind::Claude),
1109            spec("oc", AgentKind::Opencode),
1110            spec("agy", AgentKind::Antigravity),
1111        ];
1112
1113        // Config names the seat: roster order does not get a say.
1114        let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
1115        assert_eq!(by_config.id, "oc");
1116
1117        // Nothing named anywhere falls back to the built-in order, which
1118        // prefers a claude seat.
1119        let by_default = pick(&agents, None, &without(&[])).expect("default");
1120        assert_eq!(by_default.kind, AgentKind::Claude);
1121
1122        // A configured seat that is not installed is an error rather than a
1123        // silent substitution: an operator who named an interviewer had a
1124        // reason, and quietly using a different model wastes the conversation.
1125        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}