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