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