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