1use std::path::Path;
7
8use speccoder_core::actions::{Action, Runnable};
9use speccoder_core::{ApprovalsReport, CommandData, StatusReport};
10
11pub(crate) fn action(
12 group: &str,
13 label: impl Into<String>,
14 why: impl Into<String>,
15 command: impl Into<String>,
16) -> Action {
17 Action {
18 group: group.to_owned(),
19 label: label.into(),
20 why: why.into(),
21 command: command.into(),
22 run: None,
23 blocked: None,
24 input: None,
25 confirm: None,
26 }
27}
28
29#[must_use]
32pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
33 available_with(path, feature, selected, None)
34}
35
36#[must_use]
39pub fn available_with(
40 path: &Path,
41 feature: Option<&str>,
42 selected: Option<&str>,
43 listed: Option<&ApprovalsReport>,
44) -> Vec<Action> {
45 let report = crate::status::report(path, feature);
46 let report: Option<&StatusReport> = match report.data() {
47 Some(CommandData::Report(r)) => Some(r),
48 _ => None,
49 };
50 let approvals = listed.cloned().or_else(|| {
51 crate::worktree::state_dir().and_then(|state| {
52 match crate::approvals::decide_with(path, feature, crate::approvals::Verb::List, &state)
53 .data()
54 {
55 Some(CommandData::Approvals(a)) => Some((**a).clone()),
56 _ => None,
57 }
58 })
59 });
60 let uncommitted = crate::hosting::uncommitted(path, feature);
61 let skills = match crate::hosting::list(path).data() {
62 Some(CommandData::Skills(s)) => s.skills.clone(),
63 _ => Vec::new(),
64 };
65 let format = crate::format::at(path);
66 let planning = format.planning();
67 let unit = format.messages().unit;
68 let features = crate::current::list(path, None);
69 let feature_count = match features.data() {
70 Some(CommandData::Features(f)) => f.features.len(),
71 _ => 0,
72 };
73 let short = |name: &str| {
74 name.strip_prefix(planning.prefix)
75 .unwrap_or(name)
76 .to_owned()
77 };
78 let skill = |group: &str, name: &str, why: &str| {
79 let creates = planning.creates.contains(&name);
80 let mut a = action(
81 group,
82 format!("spec {name}"),
83 why,
84 if creates {
85 format!("speccoder spec {name} \"…\"")
86 } else {
87 format!("speccoder spec {name}")
88 },
89 );
90 a.run = Some(Runnable::Skill {
91 name: name.to_owned(),
92 arguments: String::new(),
93 });
94 if creates {
95 a.input = Some(format!("Describe the {unit}"));
96 }
97 a
98 };
99 let mut out = Vec::new();
100
101 for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
103 let mut a = action(
104 "next",
105 format!("approve {}'s waiting call", p.task),
106 format!("{} — {}", p.covers, p.reason),
107 format!("speccoder task approve {}", p.task),
108 );
109 a.run = Some(Runnable::Approve {
110 task: p.task.clone(),
111 });
112 out.push(a);
113 let mut r = action(
114 "next",
115 format!("reject {}'s waiting call", p.task),
116 "the model is told your reason",
117 format!("speccoder task reject {} --reason \"…\"", p.task),
118 );
119 r.run = Some(Runnable::Reject {
120 task: p.task.clone(),
121 });
122 r.input = Some("Why (the model is told)".to_owned());
123 out.push(r);
124 }
125 let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
126 for name in &planning_next {
127 let why = match name.as_str() {
128 "plan" => "write the plan from the spec",
129 "tasks" => "break the plan into tasks",
130 _ => "the next planning step",
131 };
132 out.push(skill("next", name, why));
133 }
134 if !uncommitted.is_empty() {
135 let mut a = action(
136 "next",
137 format!("commit the planning files ({})", uncommitted.len()),
138 "speccoder runs from committed files",
139 format!(
140 "git add {0} && git commit -m \"spec: …\" -- {0}",
141 uncommitted.join(" ")
142 ),
143 );
144 a.run = Some(Runnable::Commit {
145 paths: uncommitted.clone(),
146 });
147 out.push(a);
148 }
149 let ready = report.map_or(0, |r| r.totals.ready);
150 let run_blocked = (!uncommitted.is_empty())
151 .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
152 if ready > 0 && planning_next.is_empty() {
153 let mut a = action(
154 "next",
155 "run the feature",
156 format!("{ready} ready task(s), each judged on evidence"),
157 "speccoder run",
158 );
159 a.run = Some(Runnable::Run { task: None });
160 a.blocked = run_blocked.clone();
161 out.push(a);
162 }
163 let stale: Vec<&str> = report
166 .map(|r| {
167 r.tasks
168 .iter()
169 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
170 .map(|t| t.id.as_str())
171 .collect()
172 })
173 .unwrap_or_default();
174 if !stale.is_empty() && planning_next.is_empty() {
175 let mut a = action(
176 "next",
177 "re-check what the spec changed",
178 format!(
179 "{} was proven against an earlier wording; convergence assesses the code against the spec as it is now and adds tasks for what no longer holds",
180 stale.join(", ")
181 ),
182 "speccoder run --converge",
183 );
184 a.run = Some(Runnable::Converge);
185 a.blocked = run_blocked.clone();
186 out.push(a);
187 }
188 if report.is_none()
189 && feature_count == 0
190 && format.detect(path).is_some()
191 && let Some(create) = planning.creates.first()
192 {
193 out.push(skill(
194 "next",
195 create,
196 &format!("write the first {unit}'s spec"),
197 ));
198 }
199
200 for s in skills.iter().filter(|s| s.refused.is_none()) {
202 let name = short(&s.name);
203 if planning_next.contains(&name) {
204 continue;
205 }
206 out.push(skill("plan", &name, &s.description));
207 }
208
209 if let Some(t) =
211 selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
212 {
213 if t.state == "ready" {
214 let mut a = action(
215 "work",
216 format!("run {}", t.id),
217 t.title.clone(),
218 format!("speccoder run --task {}", t.id),
219 );
220 a.run = Some(Runnable::Run {
221 task: Some(t.id.clone()),
222 });
223 a.blocked = run_blocked.clone();
224 out.push(a);
225 }
226 if !matches!(
228 t.state.as_str(),
229 "verified" | "regression_only" | "attested"
230 ) {
231 for (verb, why, prompt, run) in [
232 (
233 "accept",
234 "tick it on your word (attested, not verified)",
235 "Why it is done",
236 Runnable::Accept { task: t.id.clone() },
237 ),
238 (
239 "skip",
240 "runs pass it by; what depends on it stays blocked",
241 "Why it is skipped",
242 Runnable::Skip { task: t.id.clone() },
243 ),
244 (
245 "nudge",
246 "a note its next attempts read",
247 "The note",
248 Runnable::Nudge { task: t.id.clone() },
249 ),
250 ] {
251 let flag = if verb == "nudge" {
252 "--note"
253 } else {
254 "--reason"
255 };
256 let mut a = action(
257 "task",
258 format!("{verb} {}", t.id),
259 why,
260 format!("speccoder task {verb} {} {flag} \"…\"", t.id),
261 );
262 a.run = Some(run);
263 a.input = Some(prompt.to_owned());
264 out.push(a);
265 }
266 }
267 }
268 if ready > 0 && !planning_next.is_empty() {
269 let mut a = action(
271 "work",
272 "run the feature",
273 format!("{ready} ready task(s)"),
274 "speccoder run",
275 );
276 a.run = Some(Runnable::Run { task: None });
277 a.blocked = run_blocked;
278 out.push(a);
279 }
280
281 if feature_count > 1 {
283 let mut a = action(
284 "feature",
285 format!("switch the {unit}"),
286 format!("{feature_count} {unit}s"),
287 "speccoder feature <name>",
288 );
289 a.run = Some(Runnable::Feature);
290 out.push(a);
291 }
292
293 let mut pr = action(
296 "review",
297 "open a draft pull request",
298 "push the branch and its evidence; open (or update) the draft",
299 "speccoder pr --yes",
300 );
301 pr.run = Some(Runnable::PullRequest);
302 pr.confirm = Some(
303 "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).".to_owned(),
304 );
305 out.push(pr);
306 out.push(action(
307 "review",
308 "verify the attestations",
309 "every tick against its commit and signature",
310 "speccoder attest verify",
311 ));
312 out.push(action(
313 "review",
314 "check the setup",
315 "git, the project, the config, the model key",
316 "speccoder doctor",
317 ));
318 out
319}
320
321#[must_use]
324pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
325 match crate::status::report(path, feature).data() {
326 Some(CommandData::Report(r)) => r.journey.clone(),
327 _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use speccoder_core::actions::primary;
334
335 use super::*;
336
337 #[test]
338 fn a_project_outside_git_still_gets_a_list() {
339 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
340 let list = available(dir.path(), None, None);
341 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
342 assert!(primary(&list).is_none());
343 }
344}