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