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