Skip to main content

speccoder_runtime/
hosting.rs

1//! `speccoder spec`: list the installed Agent Skills, or run one.
2//!
3//! A hosted planning skill works in your checkout, because what it writes —
4//! the feature's spec, plan and tasks — belongs there; the planning policy
5//! lets it write only the format's artifacts and run the format's scripts,
6//! and the changes are left uncommitted for you to review.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use agentplane::model::ModelProvider;
12use speccoder_core::{
13    CommandData, CommandResult, CommandStatus, SkillEntry, SkillRunReport, SkillsReport,
14};
15
16use crate::format::Planning;
17use crate::{RunOptions, progress, redact, skills, substrate, tools, verify, worktree};
18
19fn root_of(path: &Path) -> Result<PathBuf, Box<CommandResult>> {
20    match crate::format::open(path, None) {
21        Ok(opened) => Ok(PathBuf::from(&opened.snapshot.root)),
22        Err(r) if r.unmarked => Err(Box::new(CommandResult::new(
23            "skill",
24            CommandStatus::NotAProject,
25            crate::format::reference().messages().no_project,
26        ))),
27        Err(r) => Err(Box::new(CommandResult::new("skill", r.status, &r.message))),
28    }
29}
30
31/// `speccoder spec`: the installed skills.
32#[must_use]
33pub fn list(path: &Path) -> CommandResult {
34    let root = match root_of(path) {
35        Ok(r) => r,
36        Err(r) => return *r,
37    };
38    let format = crate::format::at(&root);
39    let found = skills::discover_with(&root, format.planning());
40    let message = if found.is_empty() {
41        format.messages().no_skills.to_owned()
42    } else {
43        format!(
44            "{} skill(s); run one with `speccoder spec <name> [arguments]`",
45            found.len()
46        )
47    };
48    CommandResult::new("skill", CommandStatus::Ok, &message).with_data(CommandData::Skills(
49        Box::new(SkillsReport {
50            skills: found
51                .into_iter()
52                .map(|s| SkillEntry {
53                    name: s.name,
54                    description: s.description,
55                    path: s.path,
56                    refused: s.refused,
57                })
58                .collect(),
59        }),
60    ))
61}
62
63/// `speccoder spec <name> [arguments]` against `state` with `provider`.
64pub fn run_with(
65    path: &Path,
66    name: &str,
67    arguments: &str,
68    state: &Path,
69    provider: Arc<dyn ModelProvider>,
70    secret: Option<&str>,
71    options: &RunOptions,
72) -> CommandResult {
73    run_in(
74        path, None, name, arguments, state, provider, secret, options,
75    )
76}
77
78/// [`run_with`] for `feature` (`--feature`; else the format's own
79/// selection): the skill's commands are told which unit is meant, and a
80/// unit the creating skill makes becomes current.
81///
82/// The person's review records (`review.json`) are the person's: a skill
83/// that changed one — through a command it was allowed to run — has the
84/// change undone, and the result says so.
85#[allow(clippy::too_many_arguments)]
86pub fn run_in(
87    path: &Path,
88    feature: Option<&str>,
89    name: &str,
90    arguments: &str,
91    state: &Path,
92    provider: Arc<dyn ModelProvider>,
93    secret: Option<&str>,
94    options: &RunOptions,
95) -> CommandResult {
96    let reviews = crate::review::snapshot(path);
97    let result = run_unguarded(
98        path, feature, name, arguments, state, provider, secret, options, None,
99    );
100    let restored = crate::review::restore(&reviews);
101    if restored.is_empty() {
102        return result;
103    }
104    let message = format!(
105        "{}; undone: the skill changed the person's review ({}) — only `speccoder review` writes it",
106        result.message(),
107        restored.join(", ")
108    );
109    let status = result.status();
110    result.reworded(status, &message)
111}
112
113/// A format's own step (a bug's assess skill) under `rules` instead of the
114/// format's planning rules: what it may write is narrowed to its own files.
115#[allow(clippy::too_many_arguments)]
116pub(crate) fn run_scoped(
117    path: &Path,
118    name: &str,
119    arguments: &str,
120    state: &Path,
121    provider: Arc<dyn ModelProvider>,
122    secret: Option<&str>,
123    options: &RunOptions,
124    rules: &'static crate::format::Rules,
125) -> CommandResult {
126    let reviews = crate::review::snapshot(path);
127    let result = run_unguarded(
128        path,
129        None,
130        name,
131        arguments,
132        state,
133        provider,
134        secret,
135        options,
136        Some(rules),
137    );
138    let _ = crate::review::restore(&reviews);
139    result
140}
141
142#[allow(clippy::too_many_arguments)]
143fn run_unguarded(
144    path: &Path,
145    feature: Option<&str>,
146    name: &str,
147    arguments: &str,
148    state: &Path,
149    provider: Arc<dyn ModelProvider>,
150    secret: Option<&str>,
151    options: &RunOptions,
152    scoped: Option<&'static crate::format::Rules>,
153) -> CommandResult {
154    let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
155    let s = match crate::setup(path, feature, state, "skill") {
156        Ok(s) => s,
157        Err(r) => return *r,
158    };
159    let root = PathBuf::from(&s.snapshot.root);
160    let planning = s.format.planning();
161    let installed = skills::discover_with(&root, planning);
162    let Some(skill) = installed
163        .iter()
164        .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
165    else {
166        return refuse(
167            CommandStatus::NotFound,
168            format!("no skill `{name}` (see `speccoder spec`)"),
169        );
170    };
171    if let Some(why) = &skill.refused {
172        return refuse(
173            CommandStatus::UsageError,
174            format!("`{}` is not hosted: {why}", skill.name),
175        );
176    }
177    if !skill.invocable {
178        return refuse(
179            CommandStatus::UsageError,
180            format!("`{}` is marked user-invocable: false", skill.name),
181        );
182    }
183    // Extension hooks around the format's core skills.
184    let event = core_event(planning, &skill.name).map(str::to_owned);
185    let mut hook_lines = Vec::new();
186    if let Some(e) = &event {
187        match run_hooks_in(
188            &format!("before_{e}"),
189            arguments,
190            path,
191            feature,
192            state,
193            &provider,
194            secret,
195            options,
196        ) {
197            Ok(lines) => hook_lines.extend(lines),
198            Err(stop) => return *stop,
199        }
200    }
201    let instruction = match skills::instruction(&root, skill, arguments) {
202        Ok(i) => i,
203        Err(e) => return refuse(CommandStatus::Error, e),
204    };
205    let config_text = verify::find_config(&root)
206        .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
207        .unwrap_or_default();
208    let budget = match substrate::run_config::parse(&config_text) {
209        Ok(c) => c,
210        Err(e) => {
211            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
212                .with_code("CONFIG-INVALID");
213        }
214    };
215
216    let tmp = s.plane_dir.join("tmp");
217    let before = match worktree::checkpoint(&root, &tmp) {
218        Ok(c) => c,
219        Err(e) => {
220            return refuse(
221                CommandStatus::GitPrecondition,
222                format!(
223                    "a hosted skill needs a git repository with a commit: {}",
224                    e.0
225                ),
226            );
227        }
228    };
229    // The model's turns, counted from the run's own tool calls (for the
230    // record of what this skill cost: its model calls).
231    let turns = Arc::new(Turns::default());
232    let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
233        &s.plane_dir,
234        s.model.prices,
235        Some(Arc::new(Counted {
236            turns: Arc::clone(&turns),
237            next: options.progress.clone(),
238        }) as Arc<dyn progress::Progress>),
239    ));
240    live.event(&progress::Event::TaskStarted {
241        feature: "skill".to_owned(),
242        task: skill.name.clone(),
243        title: skill.description.clone(),
244    });
245    let rt = match crate::tokio_runtime() {
246        Ok(rt) => rt,
247        Err(e) => return refuse(CommandStatus::RunFailed, e),
248    };
249    // The format's scripts run confined to the checkout.
250    let runner = match crate::sandbox::runner_at(&root) {
251        Ok((r, _)) => r,
252        Err(e) => return refuse(CommandStatus::ConfigError, e),
253    };
254    let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
255    // Which unit the skill works on. The format's scripts are told
256    // (Spec Kit: SPECIFY_FEATURE_DIRECTORY), so they never depend on a
257    // pointer file the model may not have written — except the skill that
258    // creates a unit, which a variable would redirect into an old one.
259    let short = skill
260        .name
261        .strip_prefix(planning.prefix)
262        .unwrap_or(&skill.name);
263    let creates = planning.creates.contains(&short);
264    let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
265    let env: Vec<(String, String)> = match (planning.current_env, &selected) {
266        (Some(var), Some(dir)) if !creates => {
267            vec![(var.to_owned(), canonical.join(dir).display().to_string())]
268        }
269        _ => Vec::new(),
270    };
271    let server = match tools::WorkspaceTools::new(
272        &root,
273        tools::policy::Policy::new(&[], &[])
274            .rules(scoped.unwrap_or_else(|| s.format.rules()))
275            .authoring(),
276    ) {
277        Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
278        Err(e) => return refuse(CommandStatus::Error, e.to_string()),
279    };
280    let mut input = substrate::host::input(
281        &skill.name,
282        &instruction,
283        &canonical.display().to_string(),
284        (budget.max_turns, budget.max_tool_calls),
285        options.approver.is_some(),
286    );
287    if scoped.is_some() {
288        input["settings"]["scope"] = serde_json::json!("bug");
289    }
290    let outcome = rt.block_on(async {
291        let plane = substrate::Plane::open_with(
292            &s.plane_dir,
293            &s.model,
294            Arc::clone(&provider),
295            Some(server),
296            substrate::Hooks {
297                progress: Some(Arc::clone(&live)),
298                format: Some(s.format),
299                ..substrate::Hooks::default()
300            },
301        )?;
302        // The installed skill and the person's arguments: trusted.
303        let outcome = plane
304            .skill(&skill.name, agentplane::core::Tainted::trusted(input))
305            .await?;
306        // Questions and calls are decided inline, recorded with who
307        // answered; "later" ends the skill (a hosted run is not resumed).
308        let context = crate::approvals::PresentContext {
309            root: Some(canonical.clone()),
310            ..crate::approvals::PresentContext::default()
311        };
312        plane
313            .decide_inline(outcome, options.approver.as_deref(), &context)
314            .await
315    });
316    // Always Allow answered in a hosted skill: its exact argv joins the
317    // allow list, as anywhere else.
318    let outcome = outcome.map(|i| {
319        let _ = crate::actions::allow_always_inline(
320            &root,
321            &i.always.first().map_or_else(String::new, |a| {
322                a.task.split('/').next().unwrap_or_default().to_owned()
323            }),
324            &i.always,
325            state,
326        );
327        i.outcome
328    });
329    let outcome = match outcome {
330        Ok(o) => o,
331        Err(e) => return refuse(CommandStatus::RunFailed, e.0),
332    };
333    let output = outcome
334        .output
335        .as_ref()
336        .map(|o| o.peek().clone())
337        .unwrap_or(serde_json::Value::Null);
338    let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
339    let status = if succeeded {
340        output["status"].as_str().unwrap_or("failed").to_owned()
341    } else {
342        "failed".to_owned()
343    };
344    let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
345        .and_then(|after| worktree::diff(&root, &before, &after))
346        .unwrap_or_default()
347        .into_iter()
348        .map(|c| {
349            let kind = match c.kind {
350                worktree::ChangeKind::Added => "added",
351                worktree::ChangeKind::Modified => "modified",
352                worktree::ChangeKind::Deleted => "deleted",
353            };
354            (c.path, kind.to_owned())
355        })
356        .collect();
357    let report = SkillRunReport {
358        skill: skill.name.clone(),
359        run_id: outcome.run_id.to_string(),
360        summary: output["report"]["summary"].as_str().map(str::to_owned),
361        questions: output["report"]["questions"]
362            .as_array()
363            .map(|a| {
364                a.iter()
365                    .filter_map(|q| q.as_str().map(str::to_owned))
366                    .collect()
367            })
368            .unwrap_or_default(),
369        changes,
370        tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
371        pending: output.get("pending").cloned(),
372        reason: output["reason"]
373            .as_str()
374            .map(str::to_owned)
375            .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
376        usage: output.get("usage").cloned(),
377        status: status.clone(),
378        hooks: Vec::new(),
379        current: None,
380    };
381    // A unit the creating skill made becomes current, in the format's own
382    // pointer, whether or not the model recorded it.
383    let mut report = report;
384    if creates && succeeded {
385        let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
386        let made = s
387            .format
388            .inspect(&root, None)
389            .map(|after| after.features)
390            .unwrap_or_default()
391            .into_iter()
392            .rfind(|f| !known.contains(&f.dir.as_str()));
393        if let Some(f) = made {
394            match s.format.set_current(&root, Some(&f.dir)) {
395                Ok(pointer) => {
396                    report.current = Some(format!("{} (recorded in {pointer})", f.name));
397                }
398                Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
399            }
400        }
401    } else if let Some(f) = s.snapshot.feature.as_ref() {
402        report.current = Some(format!(
403            "{} ({})",
404            f.feature.name,
405            crate::current::reason_text(s.snapshot.selection.reason, s.format)
406        ));
407    }
408    let tokens = |k: &str| {
409        report
410            .usage
411            .as_ref()
412            .and_then(|u| u[k].as_u64())
413            .unwrap_or(0)
414    };
415    let (code, message) = match status.as_str() {
416        "completed" => (
417            CommandStatus::Ok,
418            format!(
419                "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
420                skill.name,
421                report.changes.len()
422            ),
423        ),
424        "failed" => (
425            CommandStatus::RunFailed,
426            format!(
427                "{} failed: {}",
428                skill.name,
429                report.reason.clone().unwrap_or_default()
430            ),
431        ),
432        other => (
433            CommandStatus::NeedsHuman,
434            format!(
435                "{} stopped ({other}): {}",
436                skill.name,
437                // A call nobody could ask about: named, with how to let it run.
438                report
439                    .pending
440                    .as_ref()
441                    .filter(|p| p.is_object())
442                    .map(|p| tools::policy::unasked(
443                        p,
444                        &format!(
445                            "run it again with prompts (`speccoder spec {}` without `--no-prompt`) and approve it when asked",
446                            skill
447                                .name
448                                .strip_prefix(s.format.planning().prefix)
449                                .unwrap_or(&skill.name)
450                        ),
451                    ))
452                    .or_else(|| report.reason.clone().filter(|r| !r.is_empty()))
453                    .or_else(|| {
454                        (!report.questions.is_empty())
455                            .then(|| format!("open questions: {}", report.questions.join(" | ")))
456                    })
457                    .or_else(|| report
458                        .pending
459                        .as_ref()
460                        .map(|p| p["reason"].as_str().unwrap_or_default().to_owned())
461                        .filter(|r| !r.is_empty()))
462                    // The skill's own closing words: its account, not a fact.
463                    .or_else(|| report
464                        .summary
465                        .clone()
466                        .filter(|r| !r.trim().is_empty())
467                        .map(|r| format!("the skill says: {r}")))
468                    .unwrap_or_else(|| format!(
469                        "the skill ended without saying why — run it again (speccoder spec {})",
470                        skill
471                            .name
472                            .strip_prefix(s.format.planning().prefix)
473                            .unwrap_or(&skill.name)
474                    ))
475            ),
476        ),
477    };
478    // What this skill's run cost, kept for the next time it is offered
479    // (the New Feature sheet's cost line is read from these, never guessed).
480    crate::controls::record_skill_usage(
481        &s.plane_dir,
482        &skill.name,
483        [
484            tokens("input_tokens"),
485            tokens("output_tokens"),
486            tokens("cache_read_tokens"),
487            tokens("cache_write_tokens"),
488        ],
489        s.model.prices,
490        u64::from(turns.calls()),
491    );
492    live.event(&progress::Event::TaskFinished {
493        task: skill.name.clone(),
494        outcome: status,
495        message: redact(&message, secret),
496        run: None,
497        input_tokens: tokens("input_tokens"),
498        output_tokens: tokens("output_tokens"),
499        cache_read_tokens: tokens("cache_read_tokens"),
500        cache_write_tokens: tokens("cache_write_tokens"),
501        usd: None,
502        regression: None,
503    });
504    if code == CommandStatus::Ok
505        && let Some(e) = &event
506    {
507        match run_hooks_in(
508            &format!("after_{e}"),
509            arguments,
510            path,
511            feature,
512            state,
513            &provider,
514            secret,
515            options,
516        ) {
517            Ok(lines) => hook_lines.extend(lines),
518            Err(stop) => {
519                report.hooks = hook_lines;
520                return CommandResult::carried("skill", &stop)
521                    .with_data(CommandData::Skill(Box::new(report)));
522            }
523        }
524    }
525    report.hooks = hook_lines;
526    CommandResult::new("skill", code, &redact(&message, secret))
527        .with_data(CommandData::Skill(Box::new(report)))
528}
529
530/// `speccoder spec <name>` with the configured model, for `feature`.
531#[must_use]
532pub fn run(
533    path: &Path,
534    feature: Option<&str>,
535    name: &str,
536    arguments: &str,
537    options: &RunOptions,
538) -> CommandResult {
539    crate::with_driver("skill", path, None, |state, provider, key| {
540        run_in(
541            path,
542            feature,
543            name,
544            arguments,
545            state,
546            Arc::clone(provider),
547            Some(key),
548            options,
549        )
550    })
551}
552
553/// Runs the mandatory hooks of `event` as hosted skills and lists the
554/// optional ones. `Ok(lines)` to show; `Err` when a mandatory hook
555/// did not complete — the caller stops with it.
556pub fn run_hooks(
557    event: &str,
558    arguments: &str,
559    path: &Path,
560    state: &Path,
561    provider: &Arc<dyn ModelProvider>,
562    secret: Option<&str>,
563    options: &RunOptions,
564) -> Result<Vec<String>, Box<CommandResult>> {
565    run_hooks_in(
566        event, arguments, path, None, state, provider, secret, options,
567    )
568}
569
570/// [`run_hooks`] for `feature`, which the hook skills work on.
571#[allow(clippy::too_many_arguments)]
572pub fn run_hooks_in(
573    event: &str,
574    arguments: &str,
575    path: &Path,
576    feature: Option<&str>,
577    state: &Path,
578    provider: &Arc<dyn ModelProvider>,
579    secret: Option<&str>,
580    options: &RunOptions,
581) -> Result<Vec<String>, Box<CommandResult>> {
582    let root = root_of(path)?;
583    let plan = crate::hooks::plan(&root, event)
584        .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
585    let mut lines: Vec<String> = Vec::new();
586    for hook in &plan.run {
587        let note = |text: String| {
588            if let Some(p) = &options.progress {
589                p.event(&progress::Event::Notice { text });
590            }
591        };
592        note(format!(
593            "{event} hook ({}): {}",
594            hook.extension, hook.command
595        ));
596        let res = run_in(
597            path,
598            feature,
599            &hook.skill(),
600            arguments,
601            state,
602            Arc::clone(provider),
603            secret,
604            options,
605        );
606        if res.status() != CommandStatus::Ok {
607            let status = if res.status() == CommandStatus::NotFound {
608                CommandStatus::ConfigError
609            } else {
610                res.status()
611            };
612            return Err(Box::new(CommandResult::new(
613                "hooks",
614                status,
615                &format!(
616                    "mandatory {event} hook `{}` ({}) did not complete: {}",
617                    hook.command,
618                    hook.extension,
619                    res.message()
620                ),
621            )));
622        }
623        lines.push(format!(
624            "ran {event} hook {} ({})",
625            hook.command, hook.extension
626        ));
627    }
628    for hook in &plan.offer {
629        let line = crate::hooks::offer_line(event, hook);
630        if let Some(p) = &options.progress {
631            p.event(&progress::Event::Notice { text: line.clone() });
632        }
633        lines.push(line);
634    }
635    for (hook, why) in &plan.skipped {
636        lines.push(format!(
637            "skipped {event} hook {} ({}): {why}",
638            hook.command, hook.extension
639        ));
640    }
641    Ok(lines)
642}
643
644/// The `before_` / `after_` event name for a hosted core skill.
645fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
646    let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
647    planning.core.contains(&short).then_some(short)
648}
649
650/// The current unit's planning files that are not committed — new or
651/// changed files under the unit's directory. `speccoder run` reads committed
652/// files only, so these are what stands between planning and a run.
653#[must_use]
654pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
655    let format = crate::format::at(path);
656    let Some(snap) = format.open(path, feature) else {
657        return Vec::new();
658    };
659    let Some(f) = snap.feature.as_ref() else {
660        return Vec::new();
661    };
662    let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
663    crate::git::Git::at(&top)
664        .run(&[
665            "status",
666            "--porcelain",
667            "-z",
668            "--untracked-files=all",
669            "--",
670            &dir,
671        ])
672        .map(|out| {
673            String::from_utf8_lossy(&out)
674                .split('\0')
675                .filter(|e| e.len() > 3)
676                .map(|e| e[3..].to_owned())
677                .collect()
678        })
679        .unwrap_or_default()
680}
681
682/// Commits exactly `paths` (repository-root relative, inside the current
683/// unit's directory) as the unit's planning: `spec(<unit>): <files>`.
684/// Nothing else that is staged or changed goes into the commit.
685#[must_use]
686pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
687    commit_planning_as(path, feature, paths, None)
688}
689
690/// [`commit_planning`] with the commit's subject (default: the files).
691#[must_use]
692pub fn commit_planning_as(
693    path: &Path,
694    feature: Option<&str>,
695    paths: &[String],
696    subject: Option<&str>,
697) -> CommandResult {
698    let refuse = |status, m: &str| CommandResult::new("commit", status, m);
699    let format = crate::format::at(path);
700    let Some(snap) = format.open(path, feature) else {
701        return refuse(
702            CommandStatus::NotAProject,
703            &(format.messages().not_a_project)(path),
704        );
705    };
706    let Some(f) = snap.feature.as_ref() else {
707        return refuse(
708            CommandStatus::SelectionNeeded,
709            "no current feature to commit for",
710        );
711    };
712    let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
713    let dir = format!("{unit}/");
714    if paths.is_empty() {
715        return refuse(CommandStatus::NotFound, "nothing to commit");
716    }
717    // Inside the unit by its components: `<unit>/../../src/x` starts with
718    // the unit's name and is not in it.
719    let inside = |p: &str| {
720        p.strip_prefix(&dir).is_some_and(|rest| {
721            rest.split('/')
722                .all(|c| !c.is_empty() && c != "." && c != "..")
723        })
724    };
725    if let Some(p) = paths.iter().find(|p| !inside(p)) {
726        return refuse(
727            CommandStatus::UsageError,
728            &format!(
729                "`{p}` is not in {}; only planning files are committed here",
730                f.feature.dir
731            ),
732        );
733    }
734    let git = crate::git::Git::at(&top);
735    let names: Vec<&str> = paths
736        .iter()
737        .map(|p| p.rsplit('/').next().unwrap_or(p))
738        .collect();
739    let message = subject.map_or_else(
740        || format!("spec({}): {}", f.feature.name, names.join(", ")),
741        |t| format!("spec({}): {t}", f.feature.name),
742    );
743    let mut add = vec!["add", "--"];
744    add.extend(paths.iter().map(String::as_str));
745    let mut commit = vec!["commit", "-q", "-m", &message, "--"];
746    commit.extend(paths.iter().map(String::as_str));
747    match git.run(&add).and_then(|_| git.run(&commit)) {
748        Ok(_) => {
749            let id = git
750                .line(&["rev-parse", "--short", "HEAD"])
751                .unwrap_or_default();
752            CommandResult::new(
753                "commit",
754                CommandStatus::Ok,
755                &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
756            )
757        }
758        Err(e) => refuse(
759            CommandStatus::GitPrecondition,
760            &format!("commit failed: {}", e.0),
761        ),
762    }
763}
764
765/// Commits exactly `paths` (repository-relative) in the repository of
766/// `root` with `message` as given: a person-commanded write's own commit.
767/// Returns the commit id as the message.
768#[must_use]
769pub fn commit_exactly(root: &Path, paths: &[String], message: &str) -> CommandResult {
770    let refuse = |status, m: &str| CommandResult::new("commit", status, m);
771    if paths.is_empty() {
772        return refuse(CommandStatus::NotFound, "nothing to commit");
773    }
774    let (top, _) = repo_dir(root, "");
775    let git = crate::git::Git::at(&top);
776    let mut add = vec!["add", "--"];
777    add.extend(paths.iter().map(String::as_str));
778    let mut commit = vec!["commit", "-q", "-m", message, "--"];
779    commit.extend(paths.iter().map(String::as_str));
780    match git.run(&add).and_then(|_| git.run(&commit)) {
781        Ok(_) => {
782            let id = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
783            CommandResult::new("commit", CommandStatus::Ok, &id)
784        }
785        Err(e) => refuse(
786            CommandStatus::GitPrecondition,
787            &format!("commit failed: {}", e.0),
788        ),
789    }
790}
791
792/// The repository's top level and `unit_dir` (relative to `root`) relative
793/// to it: git's porcelain paths are repository-relative.
794fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
795    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
796    let top = crate::git::Git::at(root)
797        .toplevel()
798        .ok()
799        .flatten()
800        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
801    let prefix = canonical(root)
802        .strip_prefix(&top)
803        .map(|p| p.to_string_lossy().replace('\\', "/"))
804        .unwrap_or_default();
805    let dir = if prefix.is_empty() {
806        unit_dir.to_owned()
807    } else {
808        format!("{prefix}/{unit_dir}")
809    };
810    (top, dir)
811}
812
813/// The highest model turn a run's tool calls named.
814#[derive(Debug, Default)]
815struct Turns(std::sync::atomic::AtomicU32);
816
817impl Turns {
818    /// The model calls: every turn with a tool call, and the one that answered.
819    fn calls(&self) -> u32 {
820        self.0.load(std::sync::atomic::Ordering::SeqCst) + 1
821    }
822}
823
824/// A progress sink that counts the turns and passes every event on.
825#[derive(Debug)]
826struct Counted {
827    turns: Arc<Turns>,
828    next: Option<Arc<dyn progress::Progress>>,
829}
830
831impl progress::Progress for Counted {
832    fn event(&self, event: &progress::Event) {
833        if let progress::Event::ToolCall { turn, .. } = event {
834            self.turns
835                .0
836                .fetch_max(*turn, std::sync::atomic::Ordering::SeqCst);
837        }
838        if let Some(n) = &self.next {
839            n.event(event);
840        }
841    }
842}