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 BlockedByGate {
103 task: String,
105 gate: String,
107 },
108 AcceptanceWeak {
110 task: String,
112 },
113 NotDemonstrated {
115 task: String,
117 },
118 RunFailed {
120 task: Option<String>,
122 },
123 Interrupted {
125 task: String,
127 },
128 Running,
130 NothingReady,
132 Planning {
135 skills: Vec<String>,
137 },
138 ReportShown {
140 ready: usize,
142 open_approvals: usize,
144 stale: usize,
146 bug: bool,
148 },
149 ApprovalsListed {
151 first: Option<String>,
153 },
154 Decided,
156 ChecklistsOpen,
158 ConfigError,
160 GitPrecondition,
162 ArchiveAwaiting {
164 change: String,
166 },
167}
168
169#[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}