1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
11pub struct NextStep {
12 pub command: String,
14 pub why: String,
16}
17
18#[derive(Serialize, schemars::JsonSchema)]
21#[schemars(rename = "NextStep")]
22struct Shown<'a> {
23 command: &'a str,
25 why: &'a str,
27 #[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#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Outcome {
56 TaskPassed {
58 task: String,
60 ready: usize,
62 },
63 FeatureDone,
65 NeedsApproval {
67 task: String,
69 },
70 Blocked {
72 task: String,
74 },
75 BudgetExhausted {
77 task: String,
79 },
80 Incomplete {
82 task: String,
84 },
85 VerificationFailed {
87 task: String,
89 },
90 SuspectedGaming {
92 task: String,
94 },
95 TestsOverStrict {
98 task: String,
100 },
101 AcceptanceWeak {
103 task: String,
105 },
106 NotDemonstrated {
108 task: String,
110 },
111 RunFailed {
113 task: Option<String>,
115 },
116 Interrupted {
118 task: String,
120 },
121 Running,
123 NothingReady,
125 Planning {
128 skills: Vec<String>,
130 },
131 ReportShown {
133 ready: usize,
135 open_approvals: usize,
137 stale: usize,
139 },
140 ApprovalsListed {
142 first: Option<String>,
144 },
145 Decided,
147 ChecklistsOpen,
149 ConfigError,
151 GitPrecondition,
153 ArchiveAwaiting {
155 change: String,
157 },
158}
159
160#[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}