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,
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#[allow(clippy::too_many_arguments)]
114fn run_unguarded(
115    path: &Path,
116    feature: Option<&str>,
117    name: &str,
118    arguments: &str,
119    state: &Path,
120    provider: Arc<dyn ModelProvider>,
121    secret: Option<&str>,
122    options: &RunOptions,
123) -> CommandResult {
124    let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
125    let s = match crate::setup(path, feature, state, "skill") {
126        Ok(s) => s,
127        Err(r) => return *r,
128    };
129    let root = PathBuf::from(&s.snapshot.root);
130    let planning = s.format.planning();
131    let installed = skills::discover_with(&root, planning);
132    let Some(skill) = installed
133        .iter()
134        .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
135    else {
136        return refuse(
137            CommandStatus::NotFound,
138            format!("no skill `{name}` (see `speccoder spec`)"),
139        );
140    };
141    if let Some(why) = &skill.refused {
142        return refuse(
143            CommandStatus::UsageError,
144            format!("`{}` is not hosted: {why}", skill.name),
145        );
146    }
147    if !skill.invocable {
148        return refuse(
149            CommandStatus::UsageError,
150            format!("`{}` is marked user-invocable: false", skill.name),
151        );
152    }
153    // Extension hooks around the format's core skills.
154    let event = core_event(planning, &skill.name).map(str::to_owned);
155    let mut hook_lines = Vec::new();
156    if let Some(e) = &event {
157        match run_hooks_in(
158            &format!("before_{e}"),
159            arguments,
160            path,
161            feature,
162            state,
163            &provider,
164            secret,
165            options,
166        ) {
167            Ok(lines) => hook_lines.extend(lines),
168            Err(stop) => return *stop,
169        }
170    }
171    let instruction = match skills::instruction(&root, skill, arguments) {
172        Ok(i) => i,
173        Err(e) => return refuse(CommandStatus::Error, e),
174    };
175    let config_text = verify::find_config(&root)
176        .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
177        .unwrap_or_default();
178    let budget = match substrate::run_config::parse(&config_text) {
179        Ok(c) => c,
180        Err(e) => {
181            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
182                .with_code("CONFIG-INVALID");
183        }
184    };
185
186    let tmp = s.plane_dir.join("tmp");
187    let before = match worktree::checkpoint(&root, &tmp) {
188        Ok(c) => c,
189        Err(e) => {
190            return refuse(
191                CommandStatus::GitPrecondition,
192                format!(
193                    "a hosted skill needs a git repository with a commit: {}",
194                    e.0
195                ),
196            );
197        }
198    };
199    let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
200        &s.plane_dir,
201        s.model.prices,
202        options.progress.clone(),
203    ));
204    live.event(&progress::Event::TaskStarted {
205        feature: "skill".to_owned(),
206        task: skill.name.clone(),
207        title: skill.description.clone(),
208    });
209    let rt = match crate::tokio_runtime() {
210        Ok(rt) => rt,
211        Err(e) => return refuse(CommandStatus::RunFailed, e),
212    };
213    // The format's scripts run confined to the checkout.
214    let runner = match crate::sandbox::runner_at(&root) {
215        Ok((r, _)) => r,
216        Err(e) => return refuse(CommandStatus::ConfigError, e),
217    };
218    let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
219    // Which unit the skill works on. The format's scripts are told
220    // (Spec Kit: SPECIFY_FEATURE_DIRECTORY), so they never depend on a
221    // pointer file the model may not have written — except the skill that
222    // creates a unit, which a variable would redirect into an old one.
223    let short = skill
224        .name
225        .strip_prefix(planning.prefix)
226        .unwrap_or(&skill.name);
227    let creates = planning.creates.contains(&short);
228    let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
229    let env: Vec<(String, String)> = match (planning.current_env, &selected) {
230        (Some(var), Some(dir)) if !creates => {
231            vec![(var.to_owned(), canonical.join(dir).display().to_string())]
232        }
233        _ => Vec::new(),
234    };
235    let server = match tools::WorkspaceTools::new(
236        &root,
237        tools::policy::Policy::new(&[], &[])
238            .rules(s.format.rules())
239            .authoring(),
240    ) {
241        Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
242        Err(e) => return refuse(CommandStatus::Error, e.to_string()),
243    };
244    let input = substrate::host::input(
245        &skill.name,
246        &instruction,
247        &canonical.display().to_string(),
248        (budget.max_turns, budget.max_tool_calls),
249        options.approver.is_some(),
250    );
251    let outcome = rt.block_on(async {
252        let plane = substrate::Plane::open_with(
253            &s.plane_dir,
254            &s.model,
255            Arc::clone(&provider),
256            Some(server),
257            substrate::Hooks {
258                progress: Some(Arc::clone(&live)),
259                format: Some(s.format),
260                ..substrate::Hooks::default()
261            },
262        )?;
263        // The installed skill and the person's arguments: trusted.
264        let outcome = plane
265            .skill(&skill.name, agentplane::core::Tainted::trusted(input))
266            .await?;
267        // Questions and calls are decided inline, recorded with who
268        // answered; "later" ends the skill (a hosted run is not resumed).
269        let context = crate::approvals::PresentContext {
270            root: Some(canonical.clone()),
271            ..crate::approvals::PresentContext::default()
272        };
273        plane
274            .decide_inline(outcome, options.approver.as_deref(), &context)
275            .await
276            .map(|(o, _)| o)
277    });
278    let outcome = match outcome {
279        Ok(o) => o,
280        Err(e) => return refuse(CommandStatus::RunFailed, e.0),
281    };
282    let output = outcome
283        .output
284        .as_ref()
285        .map(|o| o.peek().clone())
286        .unwrap_or(serde_json::Value::Null);
287    let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
288    let status = if succeeded {
289        output["status"].as_str().unwrap_or("failed").to_owned()
290    } else {
291        "failed".to_owned()
292    };
293    let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
294        .and_then(|after| worktree::diff(&root, &before, &after))
295        .unwrap_or_default()
296        .into_iter()
297        .map(|c| {
298            let kind = match c.kind {
299                worktree::ChangeKind::Added => "added",
300                worktree::ChangeKind::Modified => "modified",
301                worktree::ChangeKind::Deleted => "deleted",
302            };
303            (c.path, kind.to_owned())
304        })
305        .collect();
306    let report = SkillRunReport {
307        skill: skill.name.clone(),
308        run_id: outcome.run_id.to_string(),
309        summary: output["report"]["summary"].as_str().map(str::to_owned),
310        questions: output["report"]["questions"]
311            .as_array()
312            .map(|a| {
313                a.iter()
314                    .filter_map(|q| q.as_str().map(str::to_owned))
315                    .collect()
316            })
317            .unwrap_or_default(),
318        changes,
319        tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
320        pending: output.get("pending").cloned(),
321        reason: output["reason"]
322            .as_str()
323            .map(str::to_owned)
324            .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
325        usage: output.get("usage").cloned(),
326        status: status.clone(),
327        hooks: Vec::new(),
328        current: None,
329    };
330    // A unit the creating skill made becomes current, in the format's own
331    // pointer, whether or not the model recorded it.
332    let mut report = report;
333    if creates && succeeded {
334        let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
335        let made = s
336            .format
337            .inspect(&root, None)
338            .map(|after| after.features)
339            .unwrap_or_default()
340            .into_iter()
341            .rfind(|f| !known.contains(&f.dir.as_str()));
342        if let Some(f) = made {
343            match s.format.set_current(&root, Some(&f.dir)) {
344                Ok(pointer) => {
345                    report.current = Some(format!("{} (recorded in {pointer})", f.name));
346                }
347                Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
348            }
349        }
350    } else if let Some(f) = s.snapshot.feature.as_ref() {
351        report.current = Some(format!(
352            "{} ({})",
353            f.feature.name,
354            crate::current::reason_text(s.snapshot.selection.reason, s.format)
355        ));
356    }
357    let tokens = |k: &str| {
358        report
359            .usage
360            .as_ref()
361            .and_then(|u| u[k].as_u64())
362            .unwrap_or(0)
363    };
364    let (code, message) = match status.as_str() {
365        "completed" => (
366            CommandStatus::Ok,
367            format!(
368                "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
369                skill.name,
370                report.changes.len()
371            ),
372        ),
373        "failed" => (
374            CommandStatus::RunFailed,
375            format!(
376                "{} failed: {}",
377                skill.name,
378                report.reason.clone().unwrap_or_default()
379            ),
380        ),
381        other => (
382            CommandStatus::NeedsHuman,
383            format!(
384                "{} stopped ({other}): {}",
385                skill.name,
386                report
387                    .reason
388                    .clone()
389                    .filter(|r| !r.is_empty())
390                    .or_else(|| {
391                        (!report.questions.is_empty())
392                            .then(|| format!("open questions: {}", report.questions.join(" | ")))
393                    })
394                    .or_else(|| report
395                        .pending
396                        .as_ref()
397                        .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
398                    .unwrap_or_default()
399            ),
400        ),
401    };
402    live.event(&progress::Event::TaskFinished {
403        task: skill.name.clone(),
404        outcome: status,
405        message: redact(&message, secret),
406        run: None,
407        input_tokens: tokens("input_tokens"),
408        output_tokens: tokens("output_tokens"),
409        cache_read_tokens: tokens("cache_read_tokens"),
410        cache_write_tokens: tokens("cache_write_tokens"),
411        usd: None,
412    });
413    if code == CommandStatus::Ok
414        && let Some(e) = &event
415    {
416        match run_hooks_in(
417            &format!("after_{e}"),
418            arguments,
419            path,
420            feature,
421            state,
422            &provider,
423            secret,
424            options,
425        ) {
426            Ok(lines) => hook_lines.extend(lines),
427            Err(stop) => {
428                report.hooks = hook_lines;
429                return CommandResult::carried("skill", &stop)
430                    .with_data(CommandData::Skill(Box::new(report)));
431            }
432        }
433    }
434    report.hooks = hook_lines;
435    CommandResult::new("skill", code, &redact(&message, secret))
436        .with_data(CommandData::Skill(Box::new(report)))
437}
438
439/// `speccoder spec <name>` with the configured model, for `feature`.
440#[must_use]
441pub fn run(
442    path: &Path,
443    feature: Option<&str>,
444    name: &str,
445    arguments: &str,
446    options: &RunOptions,
447) -> CommandResult {
448    crate::with_driver("skill", path, None, |state, provider, key| {
449        run_in(
450            path,
451            feature,
452            name,
453            arguments,
454            state,
455            Arc::clone(provider),
456            Some(key),
457            options,
458        )
459    })
460}
461
462/// Runs the mandatory hooks of `event` as hosted skills and lists the
463/// optional ones. `Ok(lines)` to show; `Err` when a mandatory hook
464/// did not complete — the caller stops with it.
465pub fn run_hooks(
466    event: &str,
467    arguments: &str,
468    path: &Path,
469    state: &Path,
470    provider: &Arc<dyn ModelProvider>,
471    secret: Option<&str>,
472    options: &RunOptions,
473) -> Result<Vec<String>, Box<CommandResult>> {
474    run_hooks_in(
475        event, arguments, path, None, state, provider, secret, options,
476    )
477}
478
479/// [`run_hooks`] for `feature`, which the hook skills work on.
480#[allow(clippy::too_many_arguments)]
481pub fn run_hooks_in(
482    event: &str,
483    arguments: &str,
484    path: &Path,
485    feature: Option<&str>,
486    state: &Path,
487    provider: &Arc<dyn ModelProvider>,
488    secret: Option<&str>,
489    options: &RunOptions,
490) -> Result<Vec<String>, Box<CommandResult>> {
491    let root = root_of(path)?;
492    let plan = crate::hooks::plan(&root, event)
493        .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
494    let mut lines: Vec<String> = Vec::new();
495    for hook in &plan.run {
496        let note = |text: String| {
497            if let Some(p) = &options.progress {
498                p.event(&progress::Event::Notice { text });
499            }
500        };
501        note(format!(
502            "{event} hook ({}): {}",
503            hook.extension, hook.command
504        ));
505        let res = run_in(
506            path,
507            feature,
508            &hook.skill(),
509            arguments,
510            state,
511            Arc::clone(provider),
512            secret,
513            options,
514        );
515        if res.status() != CommandStatus::Ok {
516            let status = if res.status() == CommandStatus::NotFound {
517                CommandStatus::ConfigError
518            } else {
519                res.status()
520            };
521            return Err(Box::new(CommandResult::new(
522                "hooks",
523                status,
524                &format!(
525                    "mandatory {event} hook `{}` ({}) did not complete: {}",
526                    hook.command,
527                    hook.extension,
528                    res.message()
529                ),
530            )));
531        }
532        lines.push(format!(
533            "ran {event} hook {} ({})",
534            hook.command, hook.extension
535        ));
536    }
537    for hook in &plan.offer {
538        let line = crate::hooks::offer_line(event, hook);
539        if let Some(p) = &options.progress {
540            p.event(&progress::Event::Notice { text: line.clone() });
541        }
542        lines.push(line);
543    }
544    for (hook, why) in &plan.skipped {
545        lines.push(format!(
546            "skipped {event} hook {} ({}): {why}",
547            hook.command, hook.extension
548        ));
549    }
550    Ok(lines)
551}
552
553/// The `before_` / `after_` event name for a hosted core skill.
554fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
555    let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
556    planning.core.contains(&short).then_some(short)
557}
558
559/// The current unit's planning files that are not committed — new or
560/// changed files under the unit's directory. `speccoder run` reads committed
561/// files only, so these are what stands between planning and a run.
562#[must_use]
563pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
564    let format = crate::format::at(path);
565    let Some(snap) = format.open(path, feature) else {
566        return Vec::new();
567    };
568    let Some(f) = snap.feature.as_ref() else {
569        return Vec::new();
570    };
571    let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
572    crate::git::Git::at(&top)
573        .run(&[
574            "status",
575            "--porcelain",
576            "-z",
577            "--untracked-files=all",
578            "--",
579            &dir,
580        ])
581        .map(|out| {
582            String::from_utf8_lossy(&out)
583                .split('\0')
584                .filter(|e| e.len() > 3)
585                .map(|e| e[3..].to_owned())
586                .collect()
587        })
588        .unwrap_or_default()
589}
590
591/// Commits exactly `paths` (repository-root relative, inside the current
592/// unit's directory) as the unit's planning: `spec(<unit>): <files>`.
593/// Nothing else that is staged or changed goes into the commit.
594#[must_use]
595pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
596    commit_planning_as(path, feature, paths, None)
597}
598
599/// [`commit_planning`] with the commit's subject (default: the files).
600#[must_use]
601pub fn commit_planning_as(
602    path: &Path,
603    feature: Option<&str>,
604    paths: &[String],
605    subject: Option<&str>,
606) -> CommandResult {
607    let refuse = |status, m: &str| CommandResult::new("commit", status, m);
608    let format = crate::format::at(path);
609    let Some(snap) = format.open(path, feature) else {
610        return refuse(
611            CommandStatus::NotAProject,
612            &(format.messages().not_a_project)(path),
613        );
614    };
615    let Some(f) = snap.feature.as_ref() else {
616        return refuse(
617            CommandStatus::SelectionNeeded,
618            "no current feature to commit for",
619        );
620    };
621    let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
622    let dir = format!("{unit}/");
623    if paths.is_empty() {
624        return refuse(CommandStatus::NotFound, "nothing to commit");
625    }
626    if let Some(p) = paths.iter().find(|p| !p.starts_with(&dir)) {
627        return refuse(
628            CommandStatus::UsageError,
629            &format!(
630                "`{p}` is not in {}; only planning files are committed here",
631                f.feature.dir
632            ),
633        );
634    }
635    let git = crate::git::Git::at(&top);
636    let names: Vec<&str> = paths
637        .iter()
638        .map(|p| p.rsplit('/').next().unwrap_or(p))
639        .collect();
640    let message = subject.map_or_else(
641        || format!("spec({}): {}", f.feature.name, names.join(", ")),
642        |t| format!("spec({}): {t}", f.feature.name),
643    );
644    let mut add = vec!["add", "--"];
645    add.extend(paths.iter().map(String::as_str));
646    let mut commit = vec!["commit", "-q", "-m", &message, "--"];
647    commit.extend(paths.iter().map(String::as_str));
648    match git.run(&add).and_then(|_| git.run(&commit)) {
649        Ok(_) => {
650            let id = git
651                .line(&["rev-parse", "--short", "HEAD"])
652                .unwrap_or_default();
653            CommandResult::new(
654                "commit",
655                CommandStatus::Ok,
656                &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
657            )
658        }
659        Err(e) => refuse(
660            CommandStatus::GitPrecondition,
661            &format!("commit failed: {}", e.0),
662        ),
663    }
664}
665
666/// The repository's top level and `unit_dir` (relative to `root`) relative
667/// to it: git's porcelain paths are repository-relative.
668fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
669    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
670    let top = crate::git::Git::at(root)
671        .toplevel()
672        .ok()
673        .flatten()
674        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
675    let prefix = canonical(root)
676        .strip_prefix(&top)
677        .map(|p| p.to_string_lossy().replace('\\', "/"))
678        .unwrap_or_default();
679    let dir = if prefix.is_empty() {
680        unit_dir.to_owned()
681    } else {
682        format!("{prefix}/{unit_dir}")
683    };
684    (top, dir)
685}