1use 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#[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
66pub 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#[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#[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#[derive(Clone, Copy)]
157pub(crate) enum Mode {
158 Plan,
160 Scoped(&'static crate::format::Rules),
162 Explore,
165}
166
167#[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 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 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 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 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 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 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 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 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 let outcome = plane
412 .skill(&skill.name, agentplane::core::Tainted::trusted(input))
413 .await?;
414 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 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 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 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 .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 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#[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
664pub 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#[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 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 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
795fn 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#[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#[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#[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 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#[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
943fn 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#[derive(Debug, Default)]
966struct Turns(std::sync::atomic::AtomicU32);
967
968impl Turns {
969 fn calls(&self) -> u32 {
971 self.0.load(std::sync::atomic::Ordering::SeqCst) + 1
972 }
973}
974
975#[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}