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