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    },
140    /// `approvals` listed.
141    ApprovalsListed {
142        /// The first task with a call waiting.
143        first: Option<String>,
144    },
145    /// A person's decision recorded (approve, reject, accept, skip, nudge).
146    Decided,
147    /// The feature's checklists have unchecked items; nothing ran.
148    ChecklistsOpen,
149    /// `speccoder.toml` or the environment is wrong.
150    ConfigError,
151    /// git refused (dirty tree, uncommitted artifacts, …).
152    GitPrecondition,
153    /// An OpenSpec archive waits for a person's yes.
154    ArchiveAwaiting {
155        /// The change.
156        change: String,
157    },
158}
159
160/// The steps that continue after `outcome`; `--feature F` is appended to
161/// speccoder's own commands when given. Total, non-empty, deterministic.
162#[must_use]
163pub fn next(outcome: &Outcome, feature: Option<&str>) -> Vec<NextStep> {
164    use Outcome as O;
165    let steps: Vec<(String, &str)> = match outcome {
166        O::NeedsApproval { task } => vec![
167            (
168                "speccoder report --approvals".to_owned(),
169                "see the call waiting and why it stopped",
170            ),
171            (
172                format!("speccoder task approve {task}"),
173                "allow it; the attempt resumes on the next run",
174            ),
175            (
176                format!("speccoder task reject {task} --reason \"…\""),
177                "refuse it; the model is told your reason",
178            ),
179            ("speccoder run".to_owned(), "continue the feature"),
180        ],
181        O::Blocked { task } => vec![
182            (
183                format!("speccoder task done {task}"),
184                "you did it: tick it on your word (attested)",
185            ),
186            (
187                format!("speccoder task hint {task} \"…\""),
188                "a hint, then a new attempt now",
189            ),
190            (
191                format!("speccoder task skip {task} --reason \"…\""),
192                "pass it by; its dependents stay blocked",
193            ),
194        ],
195        O::Incomplete { task } => vec![
196            (
197                format!("speccoder task nudge {task} --note \"…\""),
198                "say what is missing, for the next attempt",
199            ),
200            (format!("speccoder run --task {task}"), "try the task again"),
201            (
202                format!("speccoder task skip {task} --reason \"…\""),
203                "pass it by; its dependents stay blocked",
204            ),
205        ],
206        O::BudgetExhausted { task } => vec![
207            (
208                format!("speccoder task nudge {task} --note \"…\""),
209                "narrow the next attempt",
210            ),
211            (format!("speccoder run --task {task}"), "try the task again"),
212            (
213                format!("speccoder task skip {task} --reason \"…\""),
214                "pass it by; its dependents stay blocked",
215            ),
216        ],
217        O::VerificationFailed { task } => vec![
218            (
219                "speccoder report".to_owned(),
220                "see what failed and the saved attempt",
221            ),
222            (
223                format!("speccoder task nudge {task} --note \"…\""),
224                "tell the next attempt what to fix",
225            ),
226            (format!("speccoder run --task {task}"), "try the task again"),
227        ],
228        O::SuspectedGaming { task } => vec![
229            (
230                "speccoder report".to_owned(),
231                "review the saved attempt and the findings",
232            ),
233            (format!("speccoder run --task {task}"), "try the task again"),
234            (
235                format!("speccoder task skip {task} --reason \"…\""),
236                "pass it by; its dependents stay blocked",
237            ),
238        ],
239        O::TestsOverStrict { task } => vec![
240            (
241                format!("speccoder task rule {task} --tests-right --reason \"…\""),
242                "the tests are right: the spec does not allow that alternative; the next run ticks the held attempt",
243            ),
244            (
245                format!("speccoder run --task {task} --fresh-acceptance"),
246                "the tests are too strict: write them again and attempt anew",
247            ),
248            (
249                format!("speccoder task accept {task} --reason \"…\""),
250                "tick it on your word (attested, not verified)",
251            ),
252        ],
253        O::AcceptanceWeak { task } => vec![
254            (
255                "speccoder report".to_owned(),
256                "see which wrong implementations the tests let through",
257            ),
258            (
259                format!("speccoder run --task {task}"),
260                "try the task again with fresh acceptance tests",
261            ),
262            (
263                format!("speccoder task accept {task} --reason \"…\""),
264                "tick it on your word (attested, not verified)",
265            ),
266        ],
267        O::NotDemonstrated { task } => vec![
268            (
269                "speccoder report".to_owned(),
270                "see why nothing was demonstrated",
271            ),
272            (
273                format!("speccoder task nudge {task} --note \"…\""),
274                "tell the next attempt what is meant",
275            ),
276            (
277                format!("speccoder task accept {task} --reason \"…\""),
278                "tick it on your word (attested)",
279            ),
280        ],
281        O::RunFailed { task } => {
282            let mut v = vec![(
283                "speccoder report --live".to_owned(),
284                "see where the run stopped",
285            )];
286            v.push(match task {
287                Some(t) => (format!("speccoder run --task {t}"), "try the task again"),
288                None => ("speccoder run".to_owned(), "try again"),
289            });
290            v
291        }
292        O::Interrupted { .. } => vec![(
293            "speccoder run".to_owned(),
294            "resume; finished calls will not repeat",
295        )],
296        O::Running => vec![
297            ("speccoder tui".to_owned(), "watch the run"),
298            ("speccoder report --live".to_owned(), "where it is now"),
299        ],
300        O::TaskPassed { ready, .. } if *ready > 0 => vec![
301            ("speccoder run".to_owned(), "continue with the ready tasks"),
302            ("speccoder report".to_owned(), "every task and its evidence"),
303        ],
304        O::TaskPassed { .. } | O::FeatureDone => vec![
305            ("speccoder report".to_owned(), "every task and its evidence"),
306            (
307                "speccoder pr".to_owned(),
308                "hand the branch off as a draft pull request",
309            ),
310        ],
311        O::NothingReady => vec![
312            (
313                "speccoder report".to_owned(),
314                "see what blocks the open tasks",
315            ),
316            (
317                "speccoder report --approvals".to_owned(),
318                "calls waiting for a person",
319            ),
320        ],
321        O::Planning { skills } => {
322            let mut steps: Vec<(String, &str)> = skills
323                .iter()
324                .map(|s| {
325                    let why = match s.as_str() {
326                        "plan" => "write the plan from the spec",
327                        "tasks" => "break the plan into tasks",
328                        "clarify" => "settle what the spec leaves open",
329                        _ => "the next planning step",
330                    };
331                    (format!("speccoder spec {s}"), why)
332                })
333                .collect();
334            steps.push((
335                "speccoder run".to_owned(),
336                "after you review and commit what they write",
337            ));
338            steps
339        }
340        O::ReportShown { open_approvals, .. } if *open_approvals > 0 => vec![
341            (
342                "speccoder report --approvals".to_owned(),
343                "calls waiting for a person",
344            ),
345            ("speccoder run".to_owned(), "continue the feature"),
346        ],
347        O::ReportShown { stale, .. } if *stale > 0 => vec![(
348            "speccoder run --converge".to_owned(),
349            "the spec changed: re-check the code against it as it is now",
350        )],
351        O::ReportShown { ready, .. } if *ready > 0 => {
352            vec![("speccoder run".to_owned(), "continue with the ready tasks")]
353        }
354        O::ReportShown { .. } => vec![(
355            "speccoder pr".to_owned(),
356            "hand the branch off as a draft pull request",
357        )],
358        O::ApprovalsListed { first: Some(task) } => vec![
359            (
360                format!("speccoder task approve {task}"),
361                "allow it; the attempt resumes on the next run",
362            ),
363            (
364                format!("speccoder task reject {task} --reason \"…\""),
365                "refuse it; the model is told your reason",
366            ),
367        ],
368        O::ApprovalsListed { first: None } | O::Decided => {
369            vec![("speccoder run".to_owned(), "continue the feature")]
370        }
371        O::ChecklistsOpen => vec![(
372            "speccoder run --ignore-checklists".to_owned(),
373            "run anyway; recorded on every commit",
374        )],
375        O::ConfigError => vec![
376            (
377                "speccoder init".to_owned(),
378                "write or repair speccoder.toml",
379            ),
380            (
381                "speccoder verify".to_owned(),
382                "check the configured checks run",
383            ),
384        ],
385        O::GitPrecondition => vec![
386            ("git status".to_owned(), "see what is uncommitted"),
387            ("speccoder run".to_owned(), "run again once it is committed"),
388        ],
389        O::ArchiveAwaiting { change } => vec![
390            (
391                format!("openspec show {change} --diff"),
392                "see what archiving changes in the main specs",
393            ),
394            (
395                format!("speccoder spec archive {change} --yes"),
396                "archive it and commit the result",
397            ),
398        ],
399    };
400    steps
401        .into_iter()
402        .map(|(command, why)| NextStep {
403            command: match feature {
404                Some(f) if command.starts_with("speccoder ") => format!("{command} --feature {f}"),
405                _ => command,
406            },
407            why: why.to_owned(),
408        })
409        .collect()
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn commands(o: &Outcome) -> Vec<String> {
417        next(o, None).into_iter().map(|s| s.command).collect()
418    }
419
420    #[test]
421    fn every_outcome_has_its_steps() {
422        let t = || "T004".to_owned();
423        let table: Vec<(Outcome, Vec<&str>)> = vec![
424            (
425                Outcome::NeedsApproval { task: t() },
426                vec![
427                    "speccoder report --approvals",
428                    "speccoder task approve T004",
429                    "speccoder task reject T004 --reason \"…\"",
430                    "speccoder run",
431                ],
432            ),
433            (
434                Outcome::Blocked { task: t() },
435                vec![
436                    "speccoder task done T004",
437                    "speccoder task hint T004 \"…\"",
438                    "speccoder task skip T004 --reason \"…\"",
439                ],
440            ),
441            (
442                Outcome::Incomplete { task: t() },
443                vec![
444                    "speccoder task nudge T004 --note \"…\"",
445                    "speccoder run --task T004",
446                    "speccoder task skip T004 --reason \"…\"",
447                ],
448            ),
449            (
450                Outcome::BudgetExhausted { 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::VerificationFailed { task: t() },
459                vec![
460                    "speccoder report",
461                    "speccoder task nudge T004 --note \"…\"",
462                    "speccoder run --task T004",
463                ],
464            ),
465            (
466                Outcome::SuspectedGaming { task: t() },
467                vec![
468                    "speccoder report",
469                    "speccoder run --task T004",
470                    "speccoder task skip T004 --reason \"…\"",
471                ],
472            ),
473            (
474                Outcome::NotDemonstrated { task: t() },
475                vec![
476                    "speccoder report",
477                    "speccoder task nudge T004 --note \"…\"",
478                    "speccoder task accept T004 --reason \"…\"",
479                ],
480            ),
481            (
482                Outcome::RunFailed { task: Some(t()) },
483                vec!["speccoder report --live", "speccoder run --task T004"],
484            ),
485            (
486                Outcome::RunFailed { task: None },
487                vec!["speccoder report --live", "speccoder run"],
488            ),
489            (Outcome::Interrupted { task: t() }, vec!["speccoder run"]),
490            (
491                Outcome::Running,
492                vec!["speccoder tui", "speccoder report --live"],
493            ),
494            (
495                Outcome::TaskPassed {
496                    task: t(),
497                    ready: 2,
498                },
499                vec!["speccoder run", "speccoder report"],
500            ),
501            (
502                Outcome::TaskPassed {
503                    task: t(),
504                    ready: 0,
505                },
506                vec!["speccoder report", "speccoder pr"],
507            ),
508            (
509                Outcome::FeatureDone,
510                vec!["speccoder report", "speccoder pr"],
511            ),
512            (
513                Outcome::NothingReady,
514                vec!["speccoder report", "speccoder report --approvals"],
515            ),
516            (
517                Outcome::ReportShown {
518                    ready: 1,
519                    open_approvals: 1,
520                    stale: 1,
521                },
522                vec!["speccoder report --approvals", "speccoder run"],
523            ),
524            (
525                Outcome::ReportShown {
526                    ready: 1,
527                    open_approvals: 0,
528                    stale: 0,
529                },
530                vec!["speccoder run"],
531            ),
532            (
533                Outcome::ReportShown {
534                    ready: 0,
535                    open_approvals: 0,
536                    stale: 2,
537                },
538                vec!["speccoder run --converge"],
539            ),
540            (
541                Outcome::ReportShown {
542                    ready: 0,
543                    open_approvals: 0,
544                    stale: 0,
545                },
546                vec!["speccoder pr"],
547            ),
548            (
549                Outcome::ApprovalsListed { first: Some(t()) },
550                vec![
551                    "speccoder task approve T004",
552                    "speccoder task reject T004 --reason \"…\"",
553                ],
554            ),
555            (
556                Outcome::ApprovalsListed { first: None },
557                vec!["speccoder run"],
558            ),
559            (Outcome::Decided, vec!["speccoder run"]),
560            (
561                Outcome::ChecklistsOpen,
562                vec!["speccoder run --ignore-checklists"],
563            ),
564            (
565                Outcome::ConfigError,
566                vec!["speccoder init", "speccoder verify"],
567            ),
568            (
569                Outcome::GitPrecondition,
570                vec!["git status", "speccoder run"],
571            ),
572        ];
573        for (outcome, expected) in &table {
574            assert_eq!(commands(outcome), *expected, "{outcome:?}");
575            let steps = next(outcome, None);
576            assert!(!steps.is_empty());
577            assert_eq!(steps, next(outcome, None), "deterministic");
578            for s in &steps {
579                assert!(s.why.chars().count() <= 60, "{}", s.why);
580                assert!(
581                    s.why.chars().next().is_some_and(char::is_lowercase),
582                    "{}",
583                    s.why
584                );
585            }
586        }
587    }
588
589    #[test]
590    fn the_feature_is_carried_to_speccoder_commands_only() {
591        let steps = next(&Outcome::GitPrecondition, Some("001-x"));
592        assert_eq!(steps[0].command, "git status");
593        assert_eq!(steps[1].command, "speccoder run --feature 001-x");
594    }
595}