1use std::path::Path;
14use std::sync::Arc;
15
16use agentplane::model::ModelProvider;
17use speccoder_core::actions::{FeatureSource, Through};
18use speccoder_core::similar::{self, Requirement, SimilarReport};
19use speccoder_core::{
20 ChainStep, ChainStop, CommandData, CommandResult, CommandStatus, PlanCheck,
21 PlanningChainReport, Readiness,
22};
23
24use crate::RunOptions;
25
26const COMMAND: &str = "spec";
27
28struct Chain {
31 prefix: &'static str,
32 creates: Option<&'static str>,
33 toward: fn(bool) -> &'static [&'static str],
34 checks: Option<&'static str>,
35}
36
37impl Chain {
38 fn at(root: &Path) -> Self {
39 let p = crate::format::at(root).planning();
40 Self {
41 prefix: p.prefix,
42 creates: p.creates.first().copied(),
43 toward: p.toward_tasks,
44 checks: p.checks,
45 }
46 }
47
48 fn full(&self, short: &str) -> String {
49 format!("{}{short}", self.prefix)
50 }
51
52 fn remaining(&self, through: Through, has_plan: bool, has_tasks: bool) -> Vec<&'static str> {
55 let creates = self.creates;
56 let toward: Vec<&'static str> = (self.toward)(has_plan)
57 .iter()
58 .copied()
59 .filter(|s| Some(*s) != creates)
60 .collect();
61 let mut out: Vec<&'static str> = match through {
62 Through::Spec => Vec::new(),
63 Through::Plan => {
66 let all: Vec<&'static str> = (self.toward)(false)
67 .iter()
68 .copied()
69 .filter(|s| Some(*s) != creates)
70 .collect();
71 if all.len() > 1 && !has_plan {
72 all[..all.len() - 1].to_vec()
73 } else {
74 Vec::new()
75 }
76 }
77 Through::Tasks | Through::Analyze if has_tasks => Vec::new(),
78 Through::Tasks | Through::Analyze => toward,
79 };
80 if through == Through::Analyze {
81 out.extend(self.checks);
82 }
83 out
84 }
85
86 fn has(&self, short: &str) -> bool {
88 self.creates == Some(short)
89 || (self.toward)(false).contains(&short)
90 || (self.toward)(true).contains(&short)
91 || self.checks == Some(short)
92 }
93
94 fn names(&self) -> Vec<String> {
96 let mut v: Vec<String> = Vec::new();
97 for s in self
98 .creates
99 .into_iter()
100 .chain((self.toward)(false).iter().copied())
101 .chain(self.checks)
102 {
103 let f = self.full(s);
104 if !v.contains(&f) {
105 v.push(f);
106 }
107 }
108 v
109 }
110}
111
112pub fn takes_through(root: &Path, name: &str) -> Result<bool, String> {
119 let chain = Chain::at(root);
120 let short = name.strip_prefix(chain.prefix).unwrap_or(name);
121 if chain.has(short) {
122 Ok(short == chain.creates.unwrap_or_default())
123 } else {
124 Err(format!(
125 "--through plans along the format's chain: {} — not `{name}`",
126 chain.names().join(", ")
127 ))
128 }
129}
130
131fn requirement_text(id: &str, line: &str) -> String {
134 let after = line.split_once(id).map_or(line, |(_, rest)| rest);
135 after
136 .trim_start_matches(|c: char| c == '*' || c == ':' || c.is_whitespace())
137 .trim()
138 .to_owned()
139}
140
141fn corpus(root: &Path) -> (Vec<Requirement>, bool) {
145 let Ok(opened) = crate::format::open(root, None) else {
146 return (Vec::new(), false);
147 };
148 let adapter = opened.adapter;
149 let base = std::path::PathBuf::from(&opened.snapshot.root);
150 let mut units: Vec<(std::time::SystemTime, String, String)> = opened
151 .snapshot
152 .features
153 .iter()
154 .map(|f| {
155 let changed = std::fs::metadata(base.join(&f.dir))
156 .and_then(|m| m.modified())
157 .unwrap_or(std::time::UNIX_EPOCH);
158 (changed, f.name.clone(), f.dir.clone())
159 })
160 .collect();
161 units.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
162 let limited = units.len() > similar::MAX_UNITS;
163 units.truncate(similar::MAX_UNITS);
164 let mut out = Vec::new();
165 for (_, name, dir) in units {
166 if let Some(file) = adapter.requirements_file() {
167 let text = std::fs::read_to_string(base.join(&dir).join(file)).unwrap_or_default();
168 for (id, line) in adapter.requirements_in(&text) {
169 let at = text
170 .lines()
171 .position(|l| l == line || l.trim() == line.trim())
172 .and_then(|i| u32::try_from(i + 1).ok())
173 .map(|n| similar::Place {
174 path: format!("{dir}/{file}"),
175 line: n,
176 quote: line.trim().to_owned(),
177 });
178 out.push(Requirement {
179 unit: name.clone(),
180 text: requirement_text(&id, &line),
181 id,
182 at,
183 });
184 }
185 } else if let Ok(o) = crate::format::open(root, Some(&name)) {
186 for r in adapter.oracle_requirements(&o.snapshot) {
187 out.push(Requirement {
188 unit: name.clone(),
189 id: r.title.clone(),
190 text: format!("{} {}", r.title, r.text),
191 at: None,
192 });
193 }
194 }
195 }
196 (out, limited)
197}
198
199#[must_use]
202pub fn similar_to(root: &Path, text: &str) -> SimilarReport {
203 let (corpus, limited) = corpus(root);
204 SimilarReport {
205 query: text.to_owned(),
206 compared: corpus.len(),
207 limited,
208 matches: similar::matches(text, &corpus),
209 }
210}
211
212#[must_use]
214pub fn similar_command(root: &Path, text: &str) -> CommandResult {
215 let report = similar_to(root, text);
216 let message = match report.matches.first() {
217 None => "no requirement is close".to_owned(),
218 Some(m) => format!(
219 "{} close: {} {} ({}, {:.2})",
220 report.matches.len(),
221 m.unit,
222 m.requirement,
223 m.level.word(),
224 m.score
225 ),
226 };
227 CommandResult::new("feature", CommandStatus::Ok, &message)
228 .with_data(CommandData::Similar(Box::new(report)))
229}
230
231#[must_use]
234pub fn suggest(root: &Path, text: &str) -> CommandResult {
235 let mut s = speccoder_core::ladder::suggest(text);
236 let create = Chain::at(root).creates;
237 s.command = speccoder_core::ladder::command_for(s.rung, text, create);
238 let message = if s.rung == speccoder_core::ladder::RungPick::None {
239 s.reason.clone()
240 } else {
241 format!("{} — {}", s.rung.label(), s.reason)
242 };
243 let next: Vec<speccoder_core::next::NextStep> = s
244 .command
245 .iter()
246 .map(|c| speccoder_core::next::NextStep {
247 command: c.clone(),
248 why: format!("take the {} rung", s.rung.label().to_lowercase()),
249 })
250 .collect();
251 CommandResult::new("suggest", CommandStatus::Ok, &message)
252 .with_data(CommandData::RungSuggestion(Box::new(s)))
253 .with_next(next)
254}
255
256fn unit_facts(root: &Path, feature: Option<&str>) -> Option<(String, bool, bool)> {
258 let opened = crate::format::open(root, feature).ok()?;
259 let f = opened.snapshot.feature.as_ref()?;
260 Some((f.feature.name.clone(), f.artifacts.plan, f.artifacts.tasks))
261}
262
263fn plan_checks(root: &Path, unit: &str) -> Vec<PlanCheck> {
265 let Ok(o) = crate::format::open(root, Some(unit)) else {
266 return Vec::new();
267 };
268 if o.snapshot
269 .feature
270 .as_ref()
271 .is_none_or(|f| f.tasks.is_none())
272 {
273 return Vec::new();
274 }
275 crate::plan_lint::lints(o.adapter, &o.snapshot, root)
276 .into_iter()
277 .map(|l| PlanCheck {
278 code: l.code.to_owned(),
279 tasks: l.tasks,
280 detail: l.detail,
281 })
282 .collect()
283}
284
285fn step_of(result: &CommandResult, skill: &str) -> ChainStep {
286 let run_id = match result.data() {
287 Some(CommandData::Skill(r)) => Some(r.run_id.clone()).filter(|r| !r.is_empty()),
288 _ => None,
289 };
290 ChainStep {
291 skill: skill.to_owned(),
292 status: result.status(),
293 message: result.message().to_owned(),
294 run_id,
295 }
296}
297
298fn gate(root: &Path, unit: &str) -> Option<ChainStop> {
302 let review = crate::review::summary(root, Some(unit))?;
303 if !review.questions.is_empty() {
304 return Some(ChainStop::Questions {
305 questions: review.questions.iter().map(|q| q.id.clone()).collect(),
306 });
307 }
308 (!review.totals.complete() && !review.requirements.is_empty()).then(|| ChainStop::Review {
309 requirements: review.requirements.iter().map(|r| r.id.clone()).collect(),
310 })
311}
312
313fn finish(root: &Path, mut report: PlanningChainReport, options: &RunOptions) -> CommandResult {
316 let unit = report.unit.clone();
317 report.readiness = unit
318 .as_deref()
319 .and_then(|u| crate::review::readiness(root, Some(u)));
320 let chain = Chain::at(root);
321 let facts = unit.as_deref().and_then(|u| unit_facts(root, Some(u)));
322 let (has_plan, has_tasks) = facts.as_ref().map_or((false, false), |f| (f.1, f.2));
323 report.next_skill = match report.stop {
324 ChainStop::Done { .. } => None,
325 ChainStop::Failed { ref skill, .. } => Some(skill.clone()),
326 ChainStop::Questions { .. } | ChainStop::Review { .. } => chain
327 .remaining(report.through, has_plan, has_tasks)
328 .first()
329 .map(|s| chain.full(s)),
330 };
331 if let Some(p) = &options.progress {
332 p.event(&crate::progress::Event::PlanningStopped {
333 unit: unit.clone(),
334 kind: report.stop.as_str().to_owned(),
335 next_skill: report.next_skill.clone(),
336 });
337 }
338 let unit_name = unit.clone().unwrap_or_default();
339 let (status, code, message) = match &report.stop {
340 ChainStop::Questions { questions } => (
341 CommandStatus::NeedsHuman,
342 Some("THROUGH-QUESTIONS"),
343 format!(
344 "{} open question{} in {unit_name}: answer {}, then review the spec",
345 questions.len(),
346 if questions.len() == 1 { "" } else { "s" },
347 if questions.len() == 1 { "it" } else { "them" }
348 ),
349 ),
350 ChainStop::Review { requirements } => (
351 CommandStatus::NeedsHuman,
352 Some("THROUGH-REVIEW"),
353 format!(
354 "review the spec — {} requirement{}; approve them to plan",
355 requirements.len(),
356 if requirements.len() == 1 { "" } else { "s" }
357 ),
358 ),
359 ChainStop::Failed { skill, message, .. } => (
360 CommandStatus::RunFailed,
361 None,
362 format!("{skill} failed: {message}"),
363 ),
364 ChainStop::Done { plan_checks } => {
365 let steps = ["spec", "plan", "tasks"]
366 .iter()
367 .filter(|s| match **s {
368 "spec" => true,
369 "plan" => has_plan,
370 _ => has_tasks,
371 })
372 .map(|s| format!("{s} ✓"))
373 .collect::<Vec<_>>()
374 .join(" ");
375 let checks = if plan_checks.is_empty() {
376 String::new()
377 } else {
378 format!(
379 "; {} plan check{} to look at",
380 plan_checks.len(),
381 if plan_checks.len() == 1 { "" } else { "s" }
382 )
383 };
384 (
385 CommandStatus::Ok,
386 None,
387 format!(
388 "{steps} in {unit_name}{checks} — planning files are left for you to commit"
389 ),
390 )
391 }
392 };
393 let next = report.next_skill.clone();
394 let through = report.through;
395 let mut result = CommandResult::new(COMMAND, status, &message)
396 .with_data(CommandData::PlanningChain(Box::new(report)));
397 if let Some(code) = code {
398 result = result.with_code(code);
399 let mut fix = Vec::new();
400 if code == "THROUGH-QUESTIONS" {
401 fix.push("speccoder review".to_owned());
402 } else {
403 fix.push(format!("speccoder review approve --feature {unit_name}"));
404 }
405 if let Some(n) = next {
406 let short = n.strip_prefix(chain.prefix).unwrap_or(&n).to_owned();
407 fix.push(format!(
408 "speccoder spec {short} --through {} --feature {unit_name}",
409 through.as_str()
410 ));
411 }
412 result = result.with_fix(fix);
413 }
414 result
415}
416
417fn report(through: Through, source: FeatureSource) -> PlanningChainReport {
418 PlanningChainReport {
419 unit: None,
420 through,
421 ran: Vec::new(),
422 stop: ChainStop::Done {
423 plan_checks: Vec::new(),
424 },
425 readiness: None,
426 similar: Vec::new(),
427 source,
428 next_skill: None,
429 }
430}
431
432#[allow(clippy::too_many_arguments)]
434fn run_steps(
435 root: &Path,
436 unit: &str,
437 todo: &[&'static str],
438 chain: &Chain,
439 state: &Path,
440 provider: &Arc<dyn ModelProvider>,
441 secret: Option<&str>,
442 options: &RunOptions,
443 r: &mut PlanningChainReport,
444) -> bool {
445 for short in todo {
446 let name = chain.full(short);
447 let result = crate::hosting::run_in(
448 root,
449 Some(unit),
450 &name,
451 "",
452 state,
453 Arc::clone(provider),
454 secret,
455 options,
456 );
457 r.ran.push(step_of(&result, &name));
458 if result.status() != CommandStatus::Ok {
459 r.stop = ChainStop::Failed {
460 skill: name,
461 message: result.message().to_owned(),
462 code: result.code().map(|c| c.name.to_owned()),
463 };
464 return false;
465 }
466 }
467 true
468}
469
470fn record_issue(root: &Path, state: &Path, unit: &str, number: u64) {
472 let repo = crate::github::repo(root, None).ok().map(|r| r.slug());
473 let plane = crate::worktree::project_state_dir(state, root);
474 let _ = crate::ladder::record_origin(
475 &plane,
476 unit,
477 vec![speccoder_core::ladder::Origin::Issue {
478 number,
479 repo,
480 context_sha256: None,
481 }],
482 );
483}
484
485#[allow(clippy::too_many_arguments)]
487#[must_use]
488pub fn start_with(
489 root: &Path,
490 description: &str,
491 source: &FeatureSource,
492 through: Through,
493 state: &Path,
494 provider: &Arc<dyn ModelProvider>,
495 secret: Option<&str>,
496 options: &RunOptions,
497) -> CommandResult {
498 let description = description.trim();
499 if description.is_empty() {
500 return CommandResult::new("job", CommandStatus::UsageError, "a description is needed");
501 }
502 let chain = Chain::at(root);
503 let Some(creates) = chain.creates else {
504 let format = crate::format::at(root);
505 return CommandResult::new(
506 COMMAND,
507 CommandStatus::UsageError,
508 &format!(
509 "{} has no skill that creates a {}: {}",
510 format.wording().name,
511 format.messages().unit,
512 format.messages().make_project.1
513 ),
514 );
515 };
516 let mut r = report(through, source.clone());
517 r.similar = similar_to(root, description).matches;
518 if let (Some(m), Some(p)) = (r.similar.first(), &options.progress) {
519 p.event(&crate::progress::Event::Notice {
520 text: format!(
521 "warning[SIMILAR-FEATURE]: close to {} {} \"{}\" — to extend it instead: speccoder feature {} && speccoder review comments",
522 m.unit, m.requirement, m.text, m.unit
523 ),
524 });
525 }
526 let name = chain.full(creates);
527 let made = crate::hosting::run_in(
528 root,
529 None,
530 &name,
531 description,
532 state,
533 Arc::clone(provider),
534 secret,
535 options,
536 );
537 r.ran.push(step_of(&made, &name));
538 if made.status() != CommandStatus::Ok {
539 r.stop = ChainStop::Failed {
540 skill: name,
541 message: made.message().to_owned(),
542 code: made.code().map(|c| c.name.to_owned()),
543 };
544 return finish(root, r, options);
545 }
546 let Some((unit, has_plan, has_tasks)) = unit_facts(root, None) else {
547 r.stop = ChainStop::Failed {
548 skill: name,
549 message: "the skill ended without a unit speccoder can find".to_owned(),
550 code: None,
551 };
552 return finish(root, r, options);
553 };
554 if let FeatureSource::Issue { number } = source {
555 record_issue(root, state, &unit, *number);
556 }
557 r.unit = Some(unit.clone());
558 r.stop = match gate(root, &unit) {
559 Some(q @ ChainStop::Questions { .. }) => q,
560 Some(review) if through != Through::Spec => review,
561 _ if through == Through::Spec => ChainStop::Done {
562 plan_checks: Vec::new(),
563 },
564 _ if chain.remaining(through, has_plan, has_tasks).is_empty() => ChainStop::Done {
567 plan_checks: plan_checks(root, &unit),
568 },
569 _ => ChainStop::Review {
570 requirements: Vec::new(),
571 },
572 };
573 finish(root, r, options)
574}
575
576#[must_use]
578pub fn start(
579 root: &Path,
580 description: &str,
581 source: &FeatureSource,
582 through: Through,
583 options: &RunOptions,
584) -> CommandResult {
585 if description.trim().is_empty() {
586 return CommandResult::new("job", CommandStatus::UsageError, "a description is needed");
587 }
588 crate::with_driver(COMMAND, root, None, |state, provider, key| {
589 start_with(
590 root,
591 description,
592 source,
593 through,
594 state,
595 provider,
596 Some(key),
597 options,
598 )
599 })
600}
601
602#[allow(clippy::too_many_arguments)]
606#[must_use]
607pub fn resume_with(
608 root: &Path,
609 feature: Option<&str>,
610 through: Through,
611 state: &Path,
612 provider: &Arc<dyn ModelProvider>,
613 secret: Option<&str>,
614 options: &RunOptions,
615) -> CommandResult {
616 let chain = Chain::at(root);
617 let Some((unit, has_plan, has_tasks)) = unit_facts(root, feature) else {
618 return CommandResult::new(
619 COMMAND,
620 CommandStatus::SelectionNeeded,
621 "no feature to plan: start one with a description",
622 );
623 };
624 let mut r = report(through, FeatureSource::Description);
625 r.unit = Some(unit.clone());
626 if let Some(stop) = gate(root, &unit) {
627 r.stop = stop;
628 return finish(root, r, options);
629 }
630 let todo = chain.remaining(through, has_plan, has_tasks);
631 if run_steps(
632 root, &unit, &todo, &chain, state, provider, secret, options, &mut r,
633 ) {
634 r.stop = ChainStop::Done {
635 plan_checks: plan_checks(root, &unit),
636 };
637 }
638 finish(root, r, options)
639}
640
641#[must_use]
643pub fn resume(
644 root: &Path,
645 feature: Option<&str>,
646 through: Through,
647 options: &RunOptions,
648) -> CommandResult {
649 if let Some((unit, _, _)) = unit_facts(root, feature)
651 && let Some(stop) = gate(root, &unit)
652 {
653 let mut r = report(through, FeatureSource::Description);
654 r.unit = Some(unit);
655 r.stop = stop;
656 return finish(root, r, options);
657 }
658 crate::with_driver(COMMAND, root, feature, |state, provider, key| {
659 resume_with(root, feature, through, state, provider, Some(key), options)
660 })
661}
662
663#[must_use]
665pub fn readiness(root: &Path, feature: Option<&str>) -> Option<Readiness> {
666 crate::review::readiness(root, feature)
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 fn speckit() -> Chain {
674 Chain {
675 prefix: "speckit-",
676 creates: Some("specify"),
677 toward: |plan| if plan { &["tasks"] } else { &["plan", "tasks"] },
678 checks: Some("analyze"),
679 }
680 }
681
682 fn openspec() -> Chain {
683 Chain {
684 prefix: "openspec-",
685 creates: Some("propose"),
686 toward: |_| &["propose"],
687 checks: None,
688 }
689 }
690
691 #[test]
692 fn the_remaining_skills_follow_the_format_and_the_files() {
693 let s = speckit();
694 assert_eq!(s.remaining(Through::Spec, false, false), Vec::<&str>::new());
695 assert_eq!(s.remaining(Through::Plan, false, false), vec!["plan"]);
696 assert_eq!(s.remaining(Through::Plan, true, false), Vec::<&str>::new());
697 assert_eq!(
698 s.remaining(Through::Tasks, false, false),
699 vec!["plan", "tasks"]
700 );
701 assert_eq!(s.remaining(Through::Tasks, true, false), vec!["tasks"]);
702 assert_eq!(s.remaining(Through::Tasks, true, true), Vec::<&str>::new());
703 assert_eq!(s.remaining(Through::Analyze, true, true), vec!["analyze"]);
704 assert_eq!(
705 s.remaining(Through::Analyze, false, false),
706 vec!["plan", "tasks", "analyze"]
707 );
708 let o = openspec();
709 assert_eq!(
710 o.remaining(Through::Tasks, false, false),
711 Vec::<&str>::new()
712 );
713 assert_eq!(
714 o.remaining(Through::Analyze, false, false),
715 Vec::<&str>::new()
716 );
717 }
718
719 #[test]
720 fn only_the_chain_takes_through() {
721 let s = speckit();
722 assert!(s.has("specify") && s.has("plan") && s.has("tasks") && s.has("analyze"));
723 assert!(!s.has("clarify") && !s.has("checklist"));
724 assert_eq!(
725 s.names(),
726 vec![
727 "speckit-specify",
728 "speckit-plan",
729 "speckit-tasks",
730 "speckit-analyze"
731 ]
732 );
733 }
734
735 #[test]
736 fn a_requirement_line_loses_its_identifier() {
737 assert_eq!(
738 requirement_text("FR-004", "- **FR-004**: Users can export a report as PDF."),
739 "Users can export a report as PDF."
740 );
741 assert_eq!(requirement_text("FR-1", "nothing"), "nothing");
742 }
743}