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