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