Skip to main content

speccoder_core/
next.rs

1//! What to do next after a stop or a finish: a closed set of
2//! outcomes and, for each, the commands that continue — deterministic, never
3//! model text. The human `Next:` block, the TUI footer and `data.next` are
4//! all this list.
5
6use serde::{Deserialize, Serialize};
7
8/// One step a person can take. Serialised with how an interface takes it
9/// ([`crate::step::How`], beside `command` and `why`).
10#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
11pub struct NextStep {
12    /// A copy-pasteable command (`"…"` marks text the person writes).
13    pub command: String,
14    /// Why, in one short line.
15    pub why: String,
16}
17
18// A next step as it is serialised: the schema of `NextStep`.
19/// One step a person can take.
20#[derive(Serialize, schemars::JsonSchema)]
21#[schemars(rename = "NextStep")]
22struct Shown<'a> {
23    /// A copy-pasteable command (`"…"` marks text the person writes).
24    command: &'a str,
25    /// Why, in one short line.
26    why: &'a str,
27    /// How an interface takes it.
28    #[serde(flatten)]
29    how: crate::step::How,
30}
31
32impl Serialize for NextStep {
33    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
34        Shown {
35            command: &self.command,
36            why: &self.why,
37            how: crate::step::step_of(&self.command, crate::step::Context::default()).how,
38        }
39        .serialize(serializer)
40    }
41}
42
43impl schemars::JsonSchema for NextStep {
44    fn schema_name() -> std::borrow::Cow<'static, str> {
45        "NextStep".into()
46    }
47
48    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
49        Shown::json_schema(generator)
50    }
51}
52
53/// How a command ended, as far as what comes next is concerned.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Outcome {
56    /// A task passed; `ready` tasks can run now.
57    TaskPassed {
58        /// The task.
59        task: String,
60        /// Tasks ready after it.
61        ready: usize,
62    },
63    /// Every task of the feature is ticked.
64    FeatureDone,
65    /// A call waits for a person.
66    NeedsApproval {
67        /// The task.
68        task: String,
69    },
70    /// The model stopped honestly: the task cannot be done as written.
71    Blocked {
72        /// The task.
73        task: String,
74    },
75    /// Out of turns or calls.
76    BudgetExhausted {
77        /// The task.
78        task: String,
79    },
80    /// The model's closing report says the task is not finished.
81    Incomplete {
82        /// The task.
83        task: String,
84    },
85    /// The checks or the acceptance tests failed after the retries.
86    VerificationFailed {
87        /// The task.
88        task: String,
89    },
90    /// The gaming monitor suspects a shortcut.
91    SuspectedGaming {
92        /// The task.
93        task: String,
94    },
95    /// An acceptance test rejects an implementation the spec appears to
96    /// allow; the attempt is held for a ruling.
97    TestsOverStrict {
98        /// The task.
99        task: String,
100    },
101    /// The acceptance set stayed below `min_strength`.
102    AcceptanceWeak {
103        /// The task.
104        task: String,
105    },
106    /// A convergence gap no failing test shows, or unfinished checklists.
107    NotDemonstrated {
108        /// The task.
109        task: String,
110    },
111    /// The run itself failed (provider, store, …).
112    RunFailed {
113        /// The task, when one was running.
114        task: Option<String>,
115    },
116    /// Killed while running; the next run resumes.
117    Interrupted {
118        /// The task.
119        task: String,
120    },
121    /// A run is in progress.
122    Running,
123    /// No task can run.
124    NothingReady,
125    /// The feature is being planned: it has no task list yet; these
126    /// planning skills come next.
127    Planning {
128        /// The skills, in order (`plan`, `tasks`).
129        skills: Vec<String>,
130    },
131    /// `report` shown.
132    ReportShown {
133        /// Tasks ready to run.
134        ready: usize,
135        /// Calls waiting for a person.
136        open_approvals: usize,
137        /// Ticks proven against an earlier wording of what they cite.
138        stale: usize,
139        /// The unit is a bug: it runs as one.
140        bug: bool,
141    },
142    /// `approvals` listed.
143    ApprovalsListed {
144        /// The first task with a call waiting.
145        first: Option<String>,
146    },
147    /// A person's decision recorded (approve, reject, accept, skip, nudge).
148    Decided,
149    /// The feature's checklists have unchecked items; nothing ran.
150    ChecklistsOpen,
151    /// `speccoder.toml` or the environment is wrong.
152    ConfigError,
153    /// git refused (dirty tree, uncommitted artifacts, …).
154    GitPrecondition,
155    /// An OpenSpec archive waits for a person's yes.
156    ArchiveAwaiting {
157        /// The change.
158        change: String,
159    },
160}
161
162/// The steps that continue after `outcome`; `--feature F` is appended to
163/// speccoder's own commands when given. Total, non-empty, deterministic.
164#[must_use]
165pub fn next(outcome: &Outcome, feature: Option<&str>) -> Vec<NextStep> {
166    use Outcome as O;
167    let steps: Vec<(String, &str)> = match outcome {
168        O::NeedsApproval { task } => vec![
169            (
170                "speccoder report --approvals".to_owned(),
171                "see the call waiting and why it stopped",
172            ),
173            (
174                format!("speccoder task approve {task}"),
175                "allow it; the attempt resumes on the next run",
176            ),
177            (
178                format!("speccoder task reject {task} --reason \"…\""),
179                "refuse it; the model is told your reason",
180            ),
181            ("speccoder run".to_owned(), "continue the feature"),
182        ],
183        O::Blocked { task } => vec![
184            (
185                format!("speccoder task done {task}"),
186                "you did it: tick it on your word (attested)",
187            ),
188            (
189                format!("speccoder task hint {task} \"…\""),
190                "a hint, then a new attempt now",
191            ),
192            (
193                format!("speccoder task skip {task} --reason \"…\""),
194                "pass it by; its dependents stay blocked",
195            ),
196        ],
197        O::Incomplete { task } => vec![
198            (
199                format!("speccoder task nudge {task} --note \"…\""),
200                "say what is missing, for the next attempt",
201            ),
202            (format!("speccoder run --task {task}"), "try the task again"),
203            (
204                format!("speccoder task skip {task} --reason \"…\""),
205                "pass it by; its dependents stay blocked",
206            ),
207        ],
208        O::BudgetExhausted { task } => vec![
209            (
210                format!("speccoder task nudge {task} --note \"…\""),
211                "narrow the next attempt",
212            ),
213            (format!("speccoder run --task {task}"), "try the task again"),
214            (
215                format!("speccoder task skip {task} --reason \"…\""),
216                "pass it by; its dependents stay blocked",
217            ),
218        ],
219        O::VerificationFailed { task } => vec![
220            (
221                "speccoder report".to_owned(),
222                "see what failed and the saved attempt",
223            ),
224            (
225                format!("speccoder task nudge {task} --note \"…\""),
226                "tell the next attempt what to fix",
227            ),
228            (format!("speccoder run --task {task}"), "try the task again"),
229        ],
230        O::SuspectedGaming { task } => vec![
231            (
232                "speccoder report".to_owned(),
233                "review the saved attempt and the findings",
234            ),
235            (format!("speccoder run --task {task}"), "try the task again"),
236            (
237                format!("speccoder task skip {task} --reason \"…\""),
238                "pass it by; its dependents stay blocked",
239            ),
240        ],
241        O::TestsOverStrict { task } => vec![
242            (
243                format!("speccoder task rule {task} --tests-right --reason \"…\""),
244                "the tests are right: the spec does not allow that alternative; the next run ticks the held attempt",
245            ),
246            (
247                format!("speccoder run --task {task} --fresh-acceptance"),
248                "the tests are too strict: write them again and attempt anew",
249            ),
250            (
251                format!("speccoder task accept {task} --reason \"…\""),
252                "tick it on your word (attested, not verified)",
253            ),
254        ],
255        O::AcceptanceWeak { task } => vec![
256            (
257                "speccoder report".to_owned(),
258                "see which wrong implementations the tests let through",
259            ),
260            (
261                format!("speccoder run --task {task}"),
262                "try the task again with fresh acceptance tests",
263            ),
264            (
265                format!("speccoder task accept {task} --reason \"…\""),
266                "tick it on your word (attested, not verified)",
267            ),
268        ],
269        O::NotDemonstrated { task } => vec![
270            (
271                "speccoder report".to_owned(),
272                "see why nothing was demonstrated",
273            ),
274            (
275                format!("speccoder task nudge {task} --note \"…\""),
276                "tell the next attempt what is meant",
277            ),
278            (
279                format!("speccoder task accept {task} --reason \"…\""),
280                "tick it on your word (attested)",
281            ),
282        ],
283        O::RunFailed { task } => {
284            let mut v = vec![(
285                "speccoder report --live".to_owned(),
286                "see where the run stopped",
287            )];
288            v.push(match task {
289                Some(t) => (format!("speccoder run --task {t}"), "try the task again"),
290                None => ("speccoder run".to_owned(), "try again"),
291            });
292            v
293        }
294        O::Interrupted { .. } => vec![(
295            "speccoder run".to_owned(),
296            "resume; finished calls will not repeat",
297        )],
298        O::Running => vec![
299            ("speccoder tui".to_owned(), "watch the run"),
300            ("speccoder report --live".to_owned(), "where it is now"),
301        ],
302        O::TaskPassed { ready, .. } if *ready > 0 => vec![
303            ("speccoder run".to_owned(), "continue with the ready tasks"),
304            ("speccoder report".to_owned(), "every task and its evidence"),
305        ],
306        O::TaskPassed { .. } | O::FeatureDone => vec![
307            ("speccoder report".to_owned(), "every task and its evidence"),
308            (
309                "speccoder pr".to_owned(),
310                "hand the branch off as a draft pull request",
311            ),
312        ],
313        O::NothingReady => vec![
314            (
315                "speccoder report".to_owned(),
316                "see what blocks the open tasks",
317            ),
318            (
319                "speccoder report --approvals".to_owned(),
320                "calls waiting for a person",
321            ),
322        ],
323        O::Planning { skills } => {
324            let mut steps: Vec<(String, &str)> = skills
325                .iter()
326                .map(|s| {
327                    let why = match s.as_str() {
328                        "plan" => "write the plan from the spec",
329                        "tasks" => "break the plan into tasks",
330                        "clarify" => "settle what the spec leaves open",
331                        _ => "the next planning step",
332                    };
333                    (format!("speccoder spec {s}"), why)
334                })
335                .collect();
336            steps.push((
337                "speccoder run".to_owned(),
338                "after you review and commit what they write",
339            ));
340            steps
341        }
342        O::ReportShown { open_approvals, .. } if *open_approvals > 0 => vec![
343            (
344                "speccoder report --approvals".to_owned(),
345                "calls waiting for a person",
346            ),
347            ("speccoder run".to_owned(), "continue the feature"),
348        ],
349        O::ReportShown { stale, .. } if *stale > 0 => vec![(
350            "speccoder run --converge".to_owned(),
351            "the spec changed: re-check the code against it as it is now",
352        )],
353        O::ReportShown {
354            ready, bug: true, ..
355        } if *ready > 0 => vec![(
356            "speccoder run".to_owned(),
357            "run the bug: reproduce it, confirm once, fix it",
358        )],
359        O::ReportShown { ready, .. } if *ready > 0 => {
360            vec![("speccoder run".to_owned(), "continue with the ready tasks")]
361        }
362        O::ReportShown { .. } => vec![(
363            "speccoder pr".to_owned(),
364            "hand the branch off as a draft pull request",
365        )],
366        O::ApprovalsListed { first: Some(task) } => vec![
367            (
368                format!("speccoder task approve {task}"),
369                "allow it; the attempt resumes on the next run",
370            ),
371            (
372                format!("speccoder task reject {task} --reason \"…\""),
373                "refuse it; the model is told your reason",
374            ),
375        ],
376        O::ApprovalsListed { first: None } | O::Decided => {
377            vec![("speccoder run".to_owned(), "continue the feature")]
378        }
379        O::ChecklistsOpen => vec![(
380            "speccoder run --ignore-checklists".to_owned(),
381            "run anyway; recorded on every commit",
382        )],
383        O::ConfigError => vec![
384            (
385                "speccoder init".to_owned(),
386                "write or repair speccoder.toml",
387            ),
388            (
389                "speccoder verify".to_owned(),
390                "check the configured checks run",
391            ),
392        ],
393        O::GitPrecondition => vec![
394            ("git status".to_owned(), "see what is uncommitted"),
395            ("speccoder run".to_owned(), "run again once it is committed"),
396        ],
397        O::ArchiveAwaiting { change } => vec![
398            (
399                format!("openspec show {change} --diff"),
400                "see what archiving changes in the main specs",
401            ),
402            (
403                format!("speccoder spec archive {change} --yes"),
404                "archive it and commit the result",
405            ),
406        ],
407    };
408    steps
409        .into_iter()
410        .map(|(command, why)| NextStep {
411            command: match feature {
412                Some(f) if command.starts_with("speccoder ") => format!("{command} --feature {f}"),
413                _ => command,
414            },
415            why: why.to_owned(),
416        })
417        .collect()
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn commands(o: &Outcome) -> Vec<String> {
425        next(o, None).into_iter().map(|s| s.command).collect()
426    }
427
428    #[test]
429    fn every_outcome_has_its_steps() {
430        let t = || "T004".to_owned();
431        let table: Vec<(Outcome, Vec<&str>)> = vec![
432            (
433                Outcome::NeedsApproval { task: t() },
434                vec![
435                    "speccoder report --approvals",
436                    "speccoder task approve T004",
437                    "speccoder task reject T004 --reason \"…\"",
438                    "speccoder run",
439                ],
440            ),
441            (
442                Outcome::Blocked { task: t() },
443                vec![
444                    "speccoder task done T004",
445                    "speccoder task hint T004 \"…\"",
446                    "speccoder task skip T004 --reason \"…\"",
447                ],
448            ),
449            (
450                Outcome::Incomplete { task: t() },
451                vec![
452                    "speccoder task nudge T004 --note \"…\"",
453                    "speccoder run --task T004",
454                    "speccoder task skip T004 --reason \"…\"",
455                ],
456            ),
457            (
458                Outcome::BudgetExhausted { task: t() },
459                vec![
460                    "speccoder task nudge T004 --note \"…\"",
461                    "speccoder run --task T004",
462                    "speccoder task skip T004 --reason \"…\"",
463                ],
464            ),
465            (
466                Outcome::VerificationFailed { task: t() },
467                vec![
468                    "speccoder report",
469                    "speccoder task nudge T004 --note \"…\"",
470                    "speccoder run --task T004",
471                ],
472            ),
473            (
474                Outcome::SuspectedGaming { task: t() },
475                vec![
476                    "speccoder report",
477                    "speccoder run --task T004",
478                    "speccoder task skip T004 --reason \"…\"",
479                ],
480            ),
481            (
482                Outcome::NotDemonstrated { task: t() },
483                vec![
484                    "speccoder report",
485                    "speccoder task nudge T004 --note \"…\"",
486                    "speccoder task accept T004 --reason \"…\"",
487                ],
488            ),
489            (
490                Outcome::RunFailed { task: Some(t()) },
491                vec!["speccoder report --live", "speccoder run --task T004"],
492            ),
493            (
494                Outcome::RunFailed { task: None },
495                vec!["speccoder report --live", "speccoder run"],
496            ),
497            (Outcome::Interrupted { task: t() }, vec!["speccoder run"]),
498            (
499                Outcome::Running,
500                vec!["speccoder tui", "speccoder report --live"],
501            ),
502            (
503                Outcome::TaskPassed {
504                    task: t(),
505                    ready: 2,
506                },
507                vec!["speccoder run", "speccoder report"],
508            ),
509            (
510                Outcome::TaskPassed {
511                    task: t(),
512                    ready: 0,
513                },
514                vec!["speccoder report", "speccoder pr"],
515            ),
516            (
517                Outcome::FeatureDone,
518                vec!["speccoder report", "speccoder pr"],
519            ),
520            (
521                Outcome::NothingReady,
522                vec!["speccoder report", "speccoder report --approvals"],
523            ),
524            (
525                Outcome::ReportShown {
526                    bug: false,
527                    ready: 1,
528                    open_approvals: 1,
529                    stale: 1,
530                },
531                vec!["speccoder report --approvals", "speccoder run"],
532            ),
533            (
534                Outcome::ReportShown {
535                    bug: false,
536                    ready: 1,
537                    open_approvals: 0,
538                    stale: 0,
539                },
540                vec!["speccoder run"],
541            ),
542            (
543                Outcome::ReportShown {
544                    bug: false,
545                    ready: 0,
546                    open_approvals: 0,
547                    stale: 2,
548                },
549                vec!["speccoder run --converge"],
550            ),
551            (
552                Outcome::ReportShown {
553                    bug: false,
554                    ready: 0,
555                    open_approvals: 0,
556                    stale: 0,
557                },
558                vec!["speccoder pr"],
559            ),
560            (
561                Outcome::ApprovalsListed { first: Some(t()) },
562                vec![
563                    "speccoder task approve T004",
564                    "speccoder task reject T004 --reason \"…\"",
565                ],
566            ),
567            (
568                Outcome::ApprovalsListed { first: None },
569                vec!["speccoder run"],
570            ),
571            (Outcome::Decided, vec!["speccoder run"]),
572            (
573                Outcome::ChecklistsOpen,
574                vec!["speccoder run --ignore-checklists"],
575            ),
576            (
577                Outcome::ConfigError,
578                vec!["speccoder init", "speccoder verify"],
579            ),
580            (
581                Outcome::GitPrecondition,
582                vec!["git status", "speccoder run"],
583            ),
584        ];
585        for (outcome, expected) in &table {
586            assert_eq!(commands(outcome), *expected, "{outcome:?}");
587            let steps = next(outcome, None);
588            assert!(!steps.is_empty());
589            assert_eq!(steps, next(outcome, None), "deterministic");
590            for s in &steps {
591                assert!(s.why.chars().count() <= 60, "{}", s.why);
592                assert!(
593                    s.why.chars().next().is_some_and(char::is_lowercase),
594                    "{}",
595                    s.why
596                );
597            }
598        }
599    }
600
601    #[test]
602    fn the_feature_is_carried_to_speccoder_commands_only() {
603        let steps = next(&Outcome::GitPrecondition, Some("001-x"));
604        assert_eq!(steps[0].command, "git status");
605        assert_eq!(steps[1].command, "speccoder run --feature 001-x");
606    }
607}