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::queue::{self, Queue, Source, Task};
50use crate::repos;
51use crate::run;
52
53/// The task-file shape the leader is asked to produce.
54///
55/// This is handed to the leader verbatim as part of its briefing, and it is
56/// also the document [`review_draft`] enforces. The two are checked against
57/// each other by a test, because a spec that asks for something the validator
58/// does not require - or worse, the reverse - turns a good interview into a
59/// rejected draft for no reason the operator can see.
60pub const TASK_FILE_SPEC: &str = "\
61The task file is markdown. magi hands it to every candidate verbatim and to
62every judge as the statement of what was asked, so it is the only thing any of
63them knows about the change. Use this shape:
64
65# <one line, imperative: what the change is>
66
67## Context
68
69Why this change, and what a competent stranger to this repository needs to know
70that the code does not say. Name the files, the modules and the symbols
71involved, with paths.
72
73## Change
74
75What to do, in enough mechanical detail that two candidates could not
76reasonably disagree about the target: the interfaces, the names, the shape of
77the data. Leave the *design* open - how it is built, in what order, with what
78internal structure. That gap is where blind judging does its work; closing it
79turns the competition into three transcriptions of the same answer.
80
81## Constraints
82
83Anything that must hold: files that must not be touched, dependencies that must
84not be added, conventions to follow, commands that must not be run.
85
86## Completion criteria
87
88- [ ] One observable, checkable statement per line.
89- [ ] Written so that a judge holding only the diff and this list can decide
90      whether each line holds. \"Works well\" cannot be judged; \"`magi plan`
91      exits non-zero and names the draft path when the draft has no completion
92      criteria\" can.
93
94## Out of scope
95
96What this competition must not touch, so that no candidate can win on breadth
97instead of on the change that was asked for.
98
99Rules for the task itself:
100
101- One change per competition. Bundling unrelated fixes makes the diff
102  unjudgeable and the statistics meaningless.
103- Nothing destructive or irreversible. Several candidates run unattended and in
104  parallel, and no node stops to ask.
105- Visual and UX judgement stays with the operator: no judge sees a rendered
106  screen, so do not ask for one to be evaluated.
107";
108
109/// Shortest draft magi will treat as a finished task without comment.
110///
111/// Nothing magic about the number: it is roughly a title plus one criterion,
112/// and an interview that produced less than that almost always ended early.
113const MIN_DRAFT_BYTES: usize = 200;
114
115/// The problem [`review_draft`] reports for a draft that is merely suspiciously
116/// short.
117///
118/// It is a public constant because it is the *only* problem a caller may
119/// override - length alone is a smell, not a defect, and a genuinely small
120/// change deserves a small task file. Callers compare against this exact string
121/// to separate the warning from the refusals; [`plan`] does, and `magi task
122/// add` will when it starts vetting the files it is given.
123pub const SHORT_DRAFT: &str = "the draft is under 200 bytes, which is about a \
124     title and one criterion: check the interview actually finished";
125
126/// The problem reported for a draft with nothing in it at all.
127const EMPTY_DRAFT: &str = "the draft is empty";
128
129/// The problem reported for a draft with no line that could serve as a title.
130const NO_TITLE: &str = "no line in the draft can be used as a title: the first \
131     non-blank line must say what the change is";
132
133/// The problem reported for a draft with no completion criteria.
134const NO_CRITERIA: &str = "no completion criteria: add a `## Completion \
135     criteria` heading (or `## 完了条件`) with one checkable statement per line, \
136     or the candidates cannot be compared and the judges have nothing to \
137     measure against";
138
139/// Headings that mark a completion-criteria section, in the two languages this
140/// repository's operator writes tasks in.
141const CRITERIA_HEADINGS: [&str; 4] = [
142    "completion criteria",
143    "acceptance",
144    "完了条件",
145    "受け入れ基準",
146];
147
148/// What `magi plan` was asked to do.
149#[derive(Debug, Clone)]
150pub struct Opts {
151    /// The rough starting idea, if the operator gave one on the command line.
152    /// Absent is normal: the interview can start from nothing.
153    pub idea: Option<String>,
154    /// Repository the task will be competed in.
155    pub repo: PathBuf,
156    /// Explicit config file, as `--config`.
157    pub config: Option<PathBuf>,
158    /// Roster agent id to interview with. `None` picks one per the policy in
159    /// [`pick`].
160    pub agent: Option<String>,
161    /// Priority for the filed task.
162    pub priority: i32,
163    /// File the draft without the confirmation prompt.
164    pub yes: bool,
165    /// A browser-interview chat id (or unambiguous prefix/suffix) to
166    /// continue here. Its whole transcript and repository are folded into
167    /// this interview's opening briefing as background - see
168    /// [`chat::derived_background`]. `magi chat`, the CLI-side counterpart
169    /// that could name a terminal interview as `from`, does not exist yet
170    /// (issue #21), so only a browser chat can be named.
171    pub from: Option<String>,
172}
173
174impl Default for Opts {
175    fn default() -> Self {
176        Self {
177            idea: None,
178            repo: PathBuf::from("."),
179            config: None,
180            agent: None,
181            priority: 0,
182            yes: false,
183            from: None,
184        }
185    }
186}
187
188/// Interview the operator, then file the resulting task.
189///
190/// Blocks for as long as the conversation lasts, holding the terminal. Returns
191/// the task that was filed; the draft it was filed from stays on disk either
192/// way.
193pub async fn plan(opts: Opts) -> Result<Task> {
194    // The whole command is a handover of the terminal to another program's UI.
195    // Without one there is nothing to hand over, and the operator would be left
196    // watching an agent wait for input that can never arrive.
197    if !std::io::stdin().is_terminal() {
198        bail!(
199            "`magi plan` is an interview and needs a terminal. To file a task \
200             without one, pipe it to `magi task add`."
201        );
202    }
203
204    // `--repo` accepts a path or a short `owner/repo` name; see
205    // `resolve_repo`. Absolute either way, because the daemon that eventually
206    // runs this task has its own working directory and a relative path -
207    // `.`, or a short name once resolved to one - would mean the wrong
208    // repository.
209    let repo = resolve_repo(&opts.repo, opts.config.as_deref())?;
210    let repo = repo.canonicalize().unwrap_or(repo);
211    let (config, _sources) = Config::discover(&repo, opts.config.as_deref())?;
212    // `--agent` beats the config, the config beats the built-in order.
213    let want = opts.agent.as_deref().or(config.roles.planner.as_deref());
214    let leader = pick(&config.agents, want, &installed)?;
215
216    let background = from_background(&chat::Chats::open(), opts.from.as_deref())?;
217
218    let dir = drafts_dir();
219    std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
220    let id = new_id();
221    let draft = dir.join(format!("{id}.md"));
222    let brief_path = dir.join(format!("{id}.briefing.md"));
223    let mut brief = briefing(opts.idea.as_deref(), &repo, &draft, &config.graph.language);
224    if let Some(background) = &background {
225        // Prepended, so the leader reads what it is inheriting before its own
226        // instructions - the order a human handing off a conversation would
227        // use.
228        brief = format!("{background}\n\n{brief}");
229    }
230    std::fs::write(&brief_path, &brief)
231        .with_context(|| format!("write {}", brief_path.display()))?;
232
233    let argv = interactive_argv(&leader, &brief_path, &dir, &repo)?;
234
235    // Say this before handing over the terminal. `opencode` and `agy` are
236    // entered plain (see `interactive_argv`), so for those two this line is the
237    // operator's only way to know where the briefing is if the agent comes up
238    // without having read it.
239    println!("leader: {}", leader.display());
240    println!("briefing: {}", brief_path.display());
241    println!("task file goes to: {}", draft.display());
242    println!("talk it through, then let the leader write the task file and exit.\n");
243
244    let mut cmd = tokio::process::Command::new(&argv[0]);
245    cmd.args(&argv[1..])
246        .current_dir(&repo)
247        .envs(&leader.env)
248        // Inherited, not piped: the operator is talking to this CLI's own UI.
249        // Capturing any of the three would replace that UI with magi's, which
250        // is the mistake this module exists to avoid.
251        .stdin(Stdio::inherit())
252        .stdout(Stdio::inherit())
253        .stderr(Stdio::inherit());
254    // No timeout, and no `kill_on_drop`. Every other agent invocation in magi
255    // is bounded because nothing is watching it; this one is bounded by a human
256    // who is sitting right there, and killing their conversation on a clock
257    // would lose the interview.
258    let status = cmd
259        .status()
260        .await
261        .with_context(|| format!("spawn {} (is it installed?)", argv[0]))?;
262    if !status.success() {
263        // Not fatal on its own: an agent that wrote the task file and then
264        // exited badly - or that the operator quit with Ctrl-C after it had
265        // written - still leaves something worth filing. Whether there is a
266        // draft is the question that matters, and `vet` answers it next.
267        eprintln!("note: {} exited with {status}", argv[0]);
268    }
269
270    let (body, warnings) = vet(&draft)?;
271    for w in &warnings {
272        eprintln!("warning: {w}");
273    }
274
275    let title = queue::title_from(&body, 72);
276    if !opts.yes {
277        println!("\n{title}");
278        println!("draft: {} ({} bytes)", draft.display(), body.len());
279        print!("file this task? [y/N] ");
280        std::io::stdout().flush().ok();
281        let mut answer = String::new();
282        std::io::stdin()
283            .read_line(&mut answer)
284            .context("read the confirmation")?;
285        if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
286            bail!(
287                "not filed. The draft is kept at {0} - file it later with \
288                 `magi task add --file {0}`.",
289                draft.display()
290            );
291        }
292    }
293
294    let q = Queue::open();
295    let mut task = Task::new(title, body, repo, Source::Human);
296    task.priority = opts.priority;
297    q.put(&mut task)?;
298    println!("filed {} {}", task.short(), task.title);
299    Ok(task)
300}
301
302/// Is this draft usable as a magi task?
303///
304/// Separated from [`plan`] so that the rules are assertable without an
305/// interview, and so `magi task add` can reuse them for the files it is handed.
306///
307/// Every problem found is returned, not just the first: an operator about to
308/// edit a draft wants the whole list, and a validator that reveals one defect
309/// per run turns one fix into three.
310pub fn review_draft(body: &str) -> Result<(), Vec<String>> {
311    // An empty draft is reported as exactly one problem. It has no title and no
312    // criteria either, but saying so would be three ways of describing the same
313    // nothing, and the operator's next action is the same in all three cases.
314    if body.trim().is_empty() {
315        return Err(vec![EMPTY_DRAFT.to_owned()]);
316    }
317
318    let mut problems = Vec::new();
319
320    // Delegated rather than reimplemented: whatever `title_from` would accept
321    // is by definition a usable title, since it is what ends up on the task.
322    // Its placeholder is the queue's way of saying "there was nothing here".
323    if queue::title_from(body, 72) == "(empty task)" {
324        problems.push(NO_TITLE.to_owned());
325    }
326
327    if !has_completion_criteria(body) {
328        problems.push(NO_CRITERIA.to_owned());
329    }
330
331    if body.len() < MIN_DRAFT_BYTES {
332        problems.push(SHORT_DRAFT.to_owned());
333    }
334
335    if problems.is_empty() {
336        Ok(())
337    } else {
338        Err(problems)
339    }
340}
341
342/// Read the draft, check it, and split the refusals from the warnings.
343///
344/// The draft file is read and never written, moved or removed, whatever the
345/// outcome - that is what makes a rejected interview recoverable, and the error
346/// names the path so the operator does not have to guess it.
347fn vet(draft: &Path) -> Result<(String, Vec<String>)> {
348    let body = std::fs::read_to_string(draft).with_context(|| {
349        format!(
350            "no task file at {} - the leader was asked to write one there",
351            draft.display()
352        )
353    })?;
354    match review_draft(&body) {
355        Ok(()) => Ok((body, Vec::new())),
356        Err(problems) => {
357            let (soft, hard): (Vec<String>, Vec<String>) =
358                problems.into_iter().partition(|p| p == SHORT_DRAFT);
359            if hard.is_empty() {
360                return Ok((body, soft));
361            }
362            let list = hard
363                .iter()
364                .map(|p| format!("  - {p}"))
365                .collect::<Vec<_>>()
366                .join("\n");
367            bail!(
368                "the draft is not usable as a magi task:\n{list}\n\n\
369                 It is kept at {0} - nothing was thrown away. Edit it and file \
370                 it with `magi task add --file {0}`.",
371                draft.display()
372            );
373        }
374    }
375}
376
377/// Does this draft state how anyone would know the task was done?
378///
379/// Two forms count: a heading naming the section, or a checkbox list anywhere.
380/// The heading match is deliberately lenient about decoration, because the same
381/// section arrives as `## Acceptance`, `**Acceptance criteria**` or `完了条件:`
382/// depending on which CLI wrote it, and rejecting a real criteria section over
383/// asterisks would teach the operator to distrust the check. An undecorated
384/// line has to *be* the phrase, though: prose that happens to contain the word
385/// "acceptance" is not a section.
386fn has_completion_criteria(body: &str) -> bool {
387    body.lines().any(|line| {
388        let line = line.trim();
389        is_checkbox(line) || is_criteria_heading(line)
390    })
391}
392
393fn is_criteria_heading(line: &str) -> bool {
394    let decorated = line.starts_with(['#', '*', '_']);
395    let bare = line
396        .trim_start_matches(['#', '*', '_', '>', ' '])
397        .trim_end_matches(['#', '*', '_', ':', ':', ' '])
398        .trim()
399        .to_lowercase();
400    CRITERIA_HEADINGS.iter().any(|h| {
401        if decorated {
402            bare.starts_with(h)
403        } else {
404            bare == *h
405        }
406    })
407}
408
409fn is_checkbox(line: &str) -> bool {
410    let Some(rest) = line.strip_prefix(['-', '*', '+']) else {
411        return false;
412    };
413    let rest = rest.trim_start();
414    rest.starts_with("[ ]") || rest.starts_with("[x]") || rest.starts_with("[X]")
415}
416
417/// Where drafts live: under the run home, never in a temporary directory the OS
418/// may reap and never inside the repository, where it would show up as an
419/// untracked file in every candidate's worktree.
420fn drafts_dir() -> PathBuf {
421    run::home().join("drafts")
422}
423
424fn new_id() -> String {
425    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
426    let seed = crate::rng::entropy();
427    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
428}
429
430/// Resolve `--repo` to a directory.
431///
432/// An existing directory is used as-is. Anything else is tried as a short
433/// name (`owner/repo`) against `[repos] roots` - which needs a config lookup
434/// of its own, since the roots have to be known before a name can be resolved
435/// against them. That lookup uses `raw` exactly as [`Config::discover`]
436/// itself would, so a short name that happens to also be a config layer's
437/// directory is not treated specially - it already failed the `is_dir` check
438/// above, so this is purely about finding `[repos] roots`, most often from
439/// the machine layer.
440fn resolve_repo(raw: &Path, explicit_config: Option<&Path>) -> Result<PathBuf> {
441    if raw.is_dir() {
442        return Ok(raw.to_owned());
443    }
444    let (cfg, _) = Config::discover(raw, explicit_config)?;
445    repos::resolve(&cfg.repos.roots, &raw.to_string_lossy())
446}
447
448/// The background block for `--from`, if given.
449///
450/// Split out of [`plan`] so the one mistake an operator can make with this
451/// flag - naming a chat that does not exist - is testable without a
452/// terminal. Reuses [`chat::derived_background`] rather than rendering the
453/// transcript a second way, so the terminal interview and the browser
454/// interview describe a derived conversation identically.
455fn from_background(chats: &chat::Chats, from: Option<&str>) -> Result<Option<String>> {
456    match from {
457        None => Ok(None),
458        Some(id) => Ok(Some(chat::derived_background(&chats.get(id)?))),
459    }
460}
461
462/// Can this agent's CLI actually be run on this machine?
463pub fn installed(spec: &AgentSpec) -> bool {
464    // A `command` agent has no program of its own to look for - its argv is the
465    // operator's, and they are the authority on whether it runs.
466    spec.kind.program().is_none_or(which)
467}
468
469/// Choose the agent that will conduct the interview.
470///
471/// `available` is a parameter rather than a call to [`which`] so the order
472/// below is assertable on a machine with none of these CLIs installed, which is
473/// every CI runner.
474///
475/// The order, and why:
476///
477/// 1. An explicit `--agent` always wins, and is an error rather than a fallback
478///    when it is unusable. The operator naming a leader has a reason, and
479///    silently interviewing them with a different model would waste the
480///    conversation.
481/// 2. Otherwise a [`AgentKind::Claude`] seat, ahead of the roster order. It is
482///    the only one of the three CLIs magi can address before the first turn
483///    (see [`crate::agent`]'s session table), so it is the only one that can be
484///    handed the briefing as an argument and come up already knowing what the
485///    interview is for - with the others the operator has to point them at the
486///    briefing themselves. For the one command whose whole value is a smooth
487///    conversation, that difference decides it.
488/// 3. Otherwise the first runnable agent in roster order, because the roster
489///    order is the operator's own stated preference and magi has nothing better
490///    to go on.
491pub fn pick(
492    agents: &[AgentSpec],
493    want: Option<&str>,
494    available: &dyn Fn(&AgentSpec) -> bool,
495) -> Result<AgentSpec> {
496    if let Some(id) = want {
497        let spec = agents
498            .iter()
499            .find(|a| a.id == id)
500            .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
501        if !available(spec) {
502            bail!(
503                "agent `{}` needs `{}` on PATH; install it or pass a different \
504                 --agent",
505                spec.id,
506                spec.kind.program().unwrap_or("its command")
507            );
508        }
509        return Ok(spec.clone());
510    }
511
512    if agents.is_empty() {
513        bail!(
514            "the agent roster is empty, so there is nobody to plan with: \
515             install one of claude, opencode or agy - magi derives a roster \
516             from what is on PATH - or add an [[agents]] entry to magi.toml."
517        );
518    }
519
520    if let Some(spec) = agents
521        .iter()
522        .find(|a| a.kind == AgentKind::Claude && available(a))
523    {
524        return Ok(spec.clone());
525    }
526
527    agents
528        .iter()
529        .find(|a| available(a))
530        .cloned()
531        .with_context(|| {
532            let missing = agents
533                .iter()
534                .filter_map(|a| a.kind.program())
535                .collect::<Vec<_>>()
536                .join(", ");
537            format!(
538                "no agent in the roster can be run here: install one of \
539                 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
540                 you do have"
541            )
542        })
543}
544
545fn ids(agents: &[AgentSpec]) -> String {
546    if agents.is_empty() {
547        return "no agents at all".to_owned();
548    }
549    agents
550        .iter()
551        .map(|a| a.id.clone())
552        .collect::<Vec<_>>()
553        .join(", ")
554}
555
556/// The argv that puts the operator in a conversation with `spec`.
557///
558/// Deliberately *not* [`crate::agent`]'s `build_command`: that one builds the
559/// headless invocation the graph needs - `claude -p`, `opencode run`,
560/// `agy --output-format json` - which prints one answer and exits, and would
561/// turn this interview into a single non-interactive turn. Every flag there
562/// exists to make a CLI machine-readable and unattended; every flag here exists
563/// to leave it exactly as interactive as the operator is used to.
564///
565/// Two other differences from the headless path are on purpose:
566///
567/// - No permission bypass. `bypassPermissions` / `--dangerously-skip-permissions`
568///   are how an unattended node gets work done with nobody to ask. Here there
569///   is somebody to ask, sitting at the terminal, and deciding on their behalf
570///   would be magi overstepping.
571/// - `spec.extra_args` is passed only for `kind = "command"`. For the three
572///   known CLIs those arguments were written for the headless invocation - an
573///   `--output-format json` or a `--print-timeout` among them ends the
574///   interview before it starts. For a `command` agent the whole argv is the
575///   operator's, so their arguments *are* the invocation.
576fn interactive_argv(
577    spec: &AgentSpec,
578    brief_path: &Path,
579    widen: &Path,
580    repo: &Path,
581) -> Result<Vec<String>> {
582    let mut argv: Vec<String> = Vec::new();
583    match spec.kind {
584        AgentKind::Claude => {
585            argv.push("claude".to_owned());
586            if let Some(m) = &spec.model {
587                argv.push("--model".to_owned());
588                argv.push(m.clone());
589            }
590            // The briefing and the task file both live under the run home,
591            // outside the repository, so the workspace has to be widened to
592            // reach them - the same reason `agent::build_command` adds
593            // `--add-dir` for a file-delivered prompt.
594            argv.push("--add-dir".to_owned());
595            argv.push(widen.to_string_lossy().into_owned());
596            // The positional argument is claude's opening prompt, and the
597            // session stays interactive because `-p` is absent. This is the
598            // whole advantage that puts claude first in `pick`.
599            argv.push(format!(
600                "Read the file at {} and follow it. Interview me about the \
601                 change first; write the task file only once I say the plan is \
602                 right.",
603                brief_path.display()
604            ));
605        }
606        // Entered plain, in the repository. Neither CLI's interactive form has
607        // a documented way to be handed an opening prompt that magi can rely
608        // on, and guessing a flag would break the one command an operator
609        // cannot work around by editing a config file. They read the briefing
610        // because magi printed its path before handing over the terminal.
611        AgentKind::Opencode => argv.push("opencode".to_owned()),
612        AgentKind::Antigravity => {
613            argv.push("agy".to_owned());
614            argv.push("--add-dir".to_owned());
615            argv.push(widen.to_string_lossy().into_owned());
616        }
617        AgentKind::Command => {
618            if spec.command.is_empty() {
619                bail!("agent `{}` has kind = \"command\" but no command", spec.id);
620            }
621            // Same placeholders as the headless path, so an operator's existing
622            // `command` agent works here without a second spelling to learn.
623            for raw in &spec.command {
624                argv.push(
625                    raw.replace("{prompt_file}", &brief_path.to_string_lossy())
626                        .replace("{cwd}", &repo.to_string_lossy()),
627                );
628            }
629            argv.extend(spec.extra_args.iter().cloned());
630        }
631    }
632    Ok(argv)
633}
634
635/// What the leader is told before it starts talking.
636fn briefing(idea: Option<&str>, repo: &Path, out: &Path, language: &str) -> String {
637    let idea = match idea.map(str::trim).filter(|s| !s.is_empty()) {
638        Some(i) => i.to_owned(),
639        None => "The operator has not written the idea down yet. Ask them what \
640                 they want to change, starting from the repository itself."
641            .to_owned(),
642    };
643    // The interview is the operator talking, so their language matters more
644    // here than it does in any prompt the graph sends: an agent that answers a
645    // Japanese question in English makes the conversation slower for exactly
646    // the person magi is trying to help.
647    let lang = if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
648        String::new()
649    } else {
650        format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
651    };
652    format!(
653        "You are the planning leader for magi, which runs a blind \
654         multi-agent implementation competition: several agents will implement \
655         the task file you write, in isolated worktrees, unaware of each other, \
656         and judges will rank the results without knowing who wrote what.\n\n\
657         Your job is not to implement anything. It is to interview the operator \
658         until the change is pinned down, and then write one task file.\n\n\
659         # Repository\n\n{repo}\n\n\
660         Read it before you start asking. Questions that the code already \
661         answers spend the operator's patience for nothing.\n\n\
662         # The idea\n\n{idea}\n\n\
663         # How to run the interview\n\n\
664         - Ask about what you cannot determine yourself: intent, scope, which \
665         of several defensible designs the operator wants, what must not \
666         change.\n\
667         - Ask a few questions at a time and wait for the answers. Do not \
668         produce the task file after one exchange.\n\
669         - Disagree when you have grounds. A leader that agrees with everything \
670         adds nothing to what the operator already typed.\n\
671         - Confirm the plan in your own words and get an explicit yes before \
672         writing.\n\n\
673         # What to write, and where\n\n\
674         When the operator agrees the plan is right, write the task file to \
675         exactly this path:\n\n{out}\n\n\
676         Write that file and nothing else. Do not modify the repository: the \
677         competing agents do the implementation, and a repository you have \
678         already edited makes their diffs unjudgeable.\n\n\
679         magi will refuse a task file with no completion criteria, so those are \
680         not optional.\n\n\
681         # Task file specification\n\n{spec}\n\n\
682         When the file is written, tell the operator it is done and exit.{lang}",
683        repo = repo.display(),
684        out = out.display(),
685        spec = TASK_FILE_SPEC,
686    )
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    /// A task file of the shape `AGENTS.md` and [`TASK_FILE_SPEC`] describe.
694    fn good_draft() -> String {
695        "# Report per-node durations in `magi show`\n\
696         \n\
697         ## Context\n\
698         \n\
699         `report::run` prints a run's nodes but not how long each took, so the \
700         numbers behind a slow competition have to be recovered from \
701         `run.json`'s `events` with `jq`.\n\
702         \n\
703         ## Change\n\
704         \n\
705         Add a duration column to the node table in `src/report.rs`, computed \
706         from the existing `events` timestamps in `RunState`.\n\
707         \n\
708         ## Constraints\n\
709         \n\
710         No new dependencies. Do not change `run.json`'s schema.\n\
711         \n\
712         ## Completion criteria\n\
713         \n\
714         - [ ] `magi show <id>` prints a duration for every finished node.\n\
715         - [ ] A node still running prints its elapsed time, not a blank.\n\
716         - [ ] `cargo test` passes.\n\
717         \n\
718         ## Out of scope\n\
719         \n\
720         The TUI's detail pane.\n"
721            .to_owned()
722    }
723
724    fn spec(id: &str, kind: AgentKind) -> AgentSpec {
725        AgentSpec {
726            id: id.to_owned(),
727            kind,
728            model: None,
729            command: Vec::new(),
730            extra_args: Vec::new(),
731            env: Default::default(),
732            prompt_delivery: None,
733        }
734    }
735
736    /// Availability stub: an agent is runnable unless its id was listed as
737    /// missing. Keeps the selection tests off `PATH` entirely.
738    fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
739        move |a: &AgentSpec| !missing.contains(&a.id.as_str())
740    }
741
742    #[test]
743    fn a_realistic_task_file_is_accepted() {
744        let draft = good_draft();
745        assert!(
746            draft.len() >= MIN_DRAFT_BYTES,
747            "the fixture must be a real task file, not a stub"
748        );
749        assert_eq!(review_draft(&draft), Ok(()));
750    }
751
752    #[test]
753    fn a_bad_draft_reports_every_problem_at_once_rather_than_one_per_run() {
754        // Markdown decoration and nothing else: no usable title, no criteria,
755        // and far too short.
756        let problems = review_draft("###\n\n- - -\n").expect_err("must be rejected");
757        assert_eq!(problems.len(), 3, "{problems:?}");
758        assert_eq!(problems[0], NO_TITLE);
759        assert_eq!(problems[1], NO_CRITERIA);
760        assert_eq!(problems[2], SHORT_DRAFT);
761    }
762
763    #[test]
764    fn an_empty_draft_is_reported_as_empty_and_not_as_three_other_things() {
765        for body in ["", "   \n\t\n  "] {
766            let problems = review_draft(body).expect_err("must be rejected");
767            assert_eq!(problems, vec![EMPTY_DRAFT.to_owned()], "body {body:?}");
768        }
769    }
770
771    #[test]
772    fn a_draft_without_a_usable_title_is_rejected() {
773        // Long enough, and it has criteria - the title is the only defect.
774        let body = format!(
775            "#\n\n## Completion criteria\n\n- it works\n\n{}",
776            "x".repeat(300)
777        );
778        assert_eq!(
779            review_draft(&body).expect_err("must be rejected"),
780            vec![NO_TITLE.to_owned()]
781        );
782    }
783
784    #[test]
785    fn a_draft_without_completion_criteria_is_rejected_on_that_alone() {
786        let body = format!(
787            "# Rework the config loader\n\n## Change\n\nMake it layered.\n\n{}",
788            "prose. ".repeat(60)
789        );
790        assert!(body.len() >= MIN_DRAFT_BYTES);
791        assert_eq!(
792            review_draft(&body).expect_err("must be rejected"),
793            vec![NO_CRITERIA.to_owned()]
794        );
795    }
796
797    #[test]
798    fn completion_criteria_are_recognised_in_english_and_japanese_and_as_checkboxes() {
799        let filler = "x".repeat(300);
800        for section in [
801            "## Completion criteria\n\n- everything holds",
802            "## Acceptance\n\n- everything holds",
803            "### Acceptance criteria (all of them)\n\n- everything holds",
804            "**Completion criteria**\n\n- everything holds",
805            "## 完了条件\n\n- 全部そろっている",
806            "## 受け入れ基準\n\n- 全部そろっている",
807            "完了条件:\n\n- 全部そろっている",
808            "- [ ] no heading at all, just a checkbox",
809        ] {
810            let body = format!("# A real change\n\n{section}\n\n{filler}");
811            assert_eq!(
812                review_draft(&body),
813                Ok(()),
814                "must accept criteria written as {section:?}"
815            );
816        }
817    }
818
819    #[test]
820    fn prose_that_merely_mentions_acceptance_is_not_a_criteria_section() {
821        let body = format!(
822            "# A real change\n\nAcceptance of the design is up to you.\n\n{}",
823            "x".repeat(300)
824        );
825        assert_eq!(
826            review_draft(&body).expect_err("prose is not a section"),
827            vec![NO_CRITERIA.to_owned()]
828        );
829    }
830
831    #[test]
832    fn a_complete_but_tiny_draft_is_warned_about_and_not_refused() {
833        let body = "# Bump the poll interval to 5s\n\n## Completion criteria\n\n- [ ] it is 5s\n";
834        assert!(body.len() < MIN_DRAFT_BYTES);
835        let problems = review_draft(body).expect_err("must warn");
836        assert_eq!(problems, vec![SHORT_DRAFT.to_owned()]);
837
838        // And `vet` must let it through as a warning rather than a refusal,
839        // which is what makes `--yes` able to override length alone.
840        let dir = tempfile::tempdir().unwrap();
841        let path = dir.path().join("tiny.md");
842        std::fs::write(&path, body).unwrap();
843        let (read_back, warnings) = vet(&path).expect("length alone must not refuse");
844        assert_eq!(read_back, body);
845        assert_eq!(warnings, vec![SHORT_DRAFT.to_owned()]);
846    }
847
848    /// The behaviour a twenty-minute interview depends on: a draft magi refuses
849    /// is still there, byte for byte, at the path the refusal prints.
850    #[test]
851    fn a_refused_draft_is_still_on_disk_at_the_path_the_error_names() {
852        let dir = tempfile::tempdir().unwrap();
853        let path = dir.path().join("20260902-231501-ab12.md");
854        let body = "# Something the operator spent twenty minutes on\n\nBut with no criteria.\n";
855        std::fs::write(&path, body).unwrap();
856
857        let err = vet(&path).expect_err("no criteria must be refused");
858        let msg = err.to_string();
859        assert!(
860            msg.contains(&path.display().to_string()),
861            "the error must name the draft path: {msg}"
862        );
863        assert!(msg.contains("magi task add --file"), "{msg}");
864        assert_eq!(
865            std::fs::read_to_string(&path).expect("the draft must survive its refusal"),
866            body
867        );
868    }
869
870    #[test]
871    fn a_draft_lives_under_the_run_home_so_it_outlives_the_command_that_wrote_it() {
872        assert_eq!(drafts_dir(), run::home().join("drafts"));
873    }
874
875    #[test]
876    fn a_missing_draft_is_reported_against_the_path_the_leader_was_given() {
877        let dir = tempfile::tempdir().unwrap();
878        let path = dir.path().join("never-written.md");
879        let msg = vet(&path).expect_err("nothing to file").to_string();
880        assert!(msg.contains(&path.display().to_string()), "{msg}");
881    }
882
883    #[test]
884    fn the_leader_is_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
885        let agents = [
886            spec("oc", AgentKind::Opencode),
887            spec("opus", AgentKind::Claude),
888            spec("agy", AgentKind::Antigravity),
889        ];
890        let got = pick(&agents, None, &without(&[])).expect("a leader");
891        assert_eq!(got.id, "opus");
892    }
893
894    #[test]
895    fn the_leader_falls_back_to_the_first_installed_agent_in_roster_order() {
896        let agents = [
897            spec("opus", AgentKind::Claude),
898            spec("oc", AgentKind::Opencode),
899            spec("agy", AgentKind::Antigravity),
900        ];
901        let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a leader");
902        assert_eq!(got.id, "agy");
903    }
904
905    #[test]
906    fn an_empty_roster_says_what_to_install() {
907        let msg = pick(&[], None, &without(&[]))
908            .expect_err("nobody to plan with")
909            .to_string();
910        assert!(msg.contains("roster is empty"), "{msg}");
911        assert!(msg.contains("claude"), "{msg}");
912        assert!(msg.contains("magi.toml"), "{msg}");
913    }
914
915    #[test]
916    fn a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
917        let agents = [
918            spec("opus", AgentKind::Claude),
919            spec("oc", AgentKind::Opencode),
920        ];
921        let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
922        let msg = format!("{err:#}");
923        assert!(msg.contains("claude"), "{msg}");
924        assert!(msg.contains("opencode"), "{msg}");
925    }
926
927    #[test]
928    fn an_explicitly_named_agent_wins_over_the_claude_preference() {
929        let agents = [
930            spec("opus", AgentKind::Claude),
931            spec("oc", AgentKind::Opencode),
932        ];
933        let got = pick(&agents, Some("oc"), &without(&[])).expect("a leader");
934        assert_eq!(got.id, "oc");
935    }
936
937    #[test]
938    fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
939        let agents = [
940            spec("opus", AgentKind::Claude),
941            spec("oc", AgentKind::Opencode),
942        ];
943        let msg = pick(&agents, Some("gemini"), &without(&[]))
944            .expect_err("no such agent")
945            .to_string();
946        assert!(msg.contains("gemini"), "{msg}");
947        assert!(msg.contains("opus, oc"), "{msg}");
948    }
949
950    #[test]
951    fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
952        let agents = [
953            spec("opus", AgentKind::Claude),
954            spec("oc", AgentKind::Opencode),
955        ];
956        let msg = pick(&agents, Some("oc"), &without(&["oc"]))
957            .expect_err("must not silently interview with another model")
958            .to_string();
959        assert!(msg.contains("opencode"), "{msg}");
960        assert!(msg.contains("--agent"), "{msg}");
961    }
962
963    /// The spec and the validator are one contract in two places, and only this
964    /// test keeps them from drifting: a spec that stopped asking for completion
965    /// criteria would produce drafts magi refuses, with the operator following
966    /// magi's own instructions.
967    #[test]
968    fn the_task_file_spec_asks_for_the_completion_criteria_the_validator_requires() {
969        assert!(TASK_FILE_SPEC.contains("## Completion criteria"));
970        assert!(has_completion_criteria(TASK_FILE_SPEC));
971        assert_eq!(
972            review_draft(TASK_FILE_SPEC),
973            Ok(()),
974            "the spec must pass the validator it is paired with"
975        );
976    }
977
978    #[test]
979    fn the_briefing_carries_the_idea_the_repository_the_output_path_and_the_spec() {
980        let b = briefing(
981            Some("make the queue drain faster"),
982            Path::new("/src/magi"),
983            Path::new("/home/magi/drafts/x.md"),
984            "en",
985        );
986        assert!(b.contains("make the queue drain faster"));
987        assert!(b.contains("/src/magi"));
988        assert!(b.contains("/home/magi/drafts/x.md"));
989        assert!(b.contains("## Completion criteria"));
990        assert!(
991            !b.contains("Conduct the interview in"),
992            "en adds no language line"
993        );
994    }
995
996    #[test]
997    fn a_briefing_without_an_idea_tells_the_leader_to_start_the_conversation() {
998        let b = briefing(
999            Some("   "),
1000            Path::new("/src/magi"),
1001            Path::new("/o.md"),
1002            "ja",
1003        );
1004        assert!(b.contains("has not written the idea down yet"));
1005        assert!(b.contains("Conduct the interview in ja"));
1006    }
1007
1008    #[test]
1009    fn the_interactive_invocation_is_never_the_headless_one() {
1010        let brief = Path::new("/home/magi/drafts/x.briefing.md");
1011        let widen = Path::new("/home/magi/drafts");
1012        let repo = Path::new("/src/magi");
1013
1014        let mut claude = spec("opus", AgentKind::Claude);
1015        claude.model = Some("opus".to_owned());
1016        // Flags that would end the conversation, and the ones that carry it.
1017        let argv = interactive_argv(&claude, brief, widen, repo).unwrap();
1018        assert_eq!(argv[0], "claude");
1019        assert!(!argv.iter().any(|a| a == "-p" || a == "--output-format"));
1020        assert!(!argv.iter().any(|a| a == "--permission-mode"));
1021        assert!(argv.windows(2).any(|w| w == ["--model", "opus"]));
1022        assert!(
1023            argv.windows(2)
1024                .any(|w| w == ["--add-dir", "/home/magi/drafts"])
1025        );
1026        assert!(
1027            argv.last().unwrap().contains(&brief.display().to_string()),
1028            "claude gets the briefing as its opening prompt: {argv:?}"
1029        );
1030
1031        assert_eq!(
1032            interactive_argv(&spec("oc", AgentKind::Opencode), brief, widen, repo).unwrap(),
1033            vec!["opencode".to_owned()],
1034            "opencode is entered plain, in the repository"
1035        );
1036        assert_eq!(
1037            interactive_argv(&spec("agy", AgentKind::Antigravity), brief, widen, repo).unwrap(),
1038            vec![
1039                "agy".to_owned(),
1040                "--add-dir".to_owned(),
1041                "/home/magi/drafts".to_owned()
1042            ]
1043        );
1044    }
1045
1046    #[test]
1047    fn a_command_agent_gets_its_own_argv_with_the_briefing_substituted_in() {
1048        let mut cmd = spec("local", AgentKind::Command);
1049        cmd.command = vec![
1050            "my-agent".to_owned(),
1051            "--brief".to_owned(),
1052            "{prompt_file}".to_owned(),
1053            "--in".to_owned(),
1054            "{cwd}".to_owned(),
1055        ];
1056        cmd.extra_args = vec!["--interactive".to_owned()];
1057        let argv = interactive_argv(
1058            &cmd,
1059            Path::new("/b.md"),
1060            Path::new("/drafts"),
1061            Path::new("/src/magi"),
1062        )
1063        .unwrap();
1064        assert_eq!(
1065            argv,
1066            vec![
1067                "my-agent",
1068                "--brief",
1069                "/b.md",
1070                "--in",
1071                "/src/magi",
1072                "--interactive"
1073            ]
1074        );
1075
1076        let empty = spec("broken", AgentKind::Command);
1077        let msg = interactive_argv(&empty, Path::new("/b.md"), Path::new("/d"), Path::new("/r"))
1078            .expect_err("a command agent with no command cannot be spawned")
1079            .to_string();
1080        assert!(msg.contains("broken"), "{msg}");
1081    }
1082    #[test]
1083    fn the_configured_planner_is_used_and_an_explicit_agent_still_beats_it() {
1084        // The interview is the one node a human sits through, and on a phone
1085        // there is no `--agent` to type - so it has to be settable in config.
1086        let agents = [
1087            spec("opus", AgentKind::Claude),
1088            spec("oc", AgentKind::Opencode),
1089            spec("agy", AgentKind::Antigravity),
1090        ];
1091
1092        // Config names the seat: roster order does not get a say.
1093        let by_config = pick(&agents, Some("oc"), &without(&[])).expect("configured");
1094        assert_eq!(by_config.id, "oc");
1095
1096        // Nothing named anywhere falls back to the built-in order, which
1097        // prefers a claude seat.
1098        let by_default = pick(&agents, None, &without(&[])).expect("default");
1099        assert_eq!(by_default.kind, AgentKind::Claude);
1100
1101        // A configured seat that is not installed is an error rather than a
1102        // silent substitution: an operator who named an interviewer had a
1103        // reason, and quietly using a different model wastes the conversation.
1104        let err = pick(&agents, Some("oc"), &without(&["oc"])).expect_err("not runnable");
1105        assert!(err.to_string().contains("oc"), "{err}");
1106    }
1107
1108    #[test]
1109    fn resolve_repo_uses_an_existing_directory_as_is() {
1110        let dir = tempfile::tempdir().unwrap();
1111        let resolved = resolve_repo(dir.path(), None).expect("an existing directory resolves");
1112        assert_eq!(resolved, dir.path());
1113    }
1114
1115    #[test]
1116    fn resolve_repo_resolves_a_short_name_against_configured_roots() {
1117        let tmp = tempfile::tempdir().unwrap();
1118        let root = tmp.path().join("root");
1119        let checkout = root.join("github.com").join("yukimemi").join("magi");
1120        std::fs::create_dir_all(checkout.join(".git")).unwrap();
1121
1122        let config_path = tmp.path().join("machine.toml");
1123        std::fs::write(
1124            &config_path,
1125            format!(
1126                "[repos]\nroots = [{:?}]\n",
1127                root.to_string_lossy().into_owned()
1128            ),
1129        )
1130        .unwrap();
1131
1132        let resolved =
1133            resolve_repo(Path::new("yukimemi/magi"), Some(&config_path)).expect("must resolve");
1134        assert_eq!(resolved, checkout.canonicalize().unwrap());
1135    }
1136
1137    #[test]
1138    fn resolve_repo_reports_an_unresolvable_short_name() {
1139        let tmp = tempfile::tempdir().unwrap();
1140        let config_path = tmp.path().join("machine.toml");
1141        std::fs::write(&config_path, "[repos]\nroots = []\n").unwrap();
1142
1143        let err = resolve_repo(Path::new("nope/nope"), Some(&config_path))
1144            .expect_err("nothing configured to match")
1145            .to_string();
1146        assert!(err.contains("nope/nope"), "{err}");
1147    }
1148
1149    #[test]
1150    fn from_background_is_none_when_no_chat_is_named() {
1151        let tmp = tempfile::tempdir().unwrap();
1152        let chats = chat::Chats::at(tmp.path().join("chats"));
1153        assert_eq!(from_background(&chats, None).unwrap(), None);
1154    }
1155
1156    #[test]
1157    fn from_background_names_the_missing_chat_id() {
1158        let tmp = tempfile::tempdir().unwrap();
1159        let chats = chat::Chats::at(tmp.path().join("chats"));
1160        let err = from_background(&chats, Some("nope"))
1161            .expect_err("no such chat")
1162            .to_string();
1163        assert!(err.contains("nope"), "{err}");
1164    }
1165}