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