Skip to main content

speccoder_runtime/
actions.rs

1//! What a person can do now, from the project as it stands: the
2//! report, what waits for a decision, the planning files not yet committed
3//! and the installed planning skills. Interfaces render the list; the
4//! dashboard runs what it can and shows the command for the rest.
5
6use 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/// What can be done now in the project at `path` (`feature` as for every
30/// command), with `selected` the task an interface has selected.
31#[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/// [`available`] with the waiting calls already listed (an interface that
37/// lists them anyway): the journal is not opened again.
38#[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    // ── next: what moves the work forward, most urgent first ──────────
102    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    // The spec changed under ticks that cite it: their evidence is for the
164    // earlier wording. Convergence assesses the code against it as it is.
165    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    // ── plan: every hosted planning skill ─────────────────────────────
201    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    // ── work ───────────────────────────────────────────────────────────
210    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        // ── task: the selected one ────────────────────────────────────
227        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        // Tasks exist and planning goes on: running is possible too.
270        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    // ── feature ────────────────────────────────────────────────────────
282    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    // ── review: commands that leave the machine or are best read in full ─
294    // It leaves the machine: an interface confirms it first.
295    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/// Where the feature is on its journey (spec → plan → tasks → commit → run
322/// → review); the report's, or the start of it.
323#[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}