1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10pub struct NextStep {
11 pub command: String,
13 pub why: String,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Outcome {
20 TaskPassed {
22 task: String,
24 ready: usize,
26 },
27 FeatureDone,
29 NeedsApproval {
31 task: String,
33 },
34 Blocked {
36 task: String,
38 },
39 BudgetExhausted {
41 task: String,
43 },
44 VerificationFailed {
46 task: String,
48 },
49 SuspectedGaming {
51 task: String,
53 },
54 TestsOverStrict {
57 task: String,
59 },
60 AcceptanceWeak {
62 task: String,
64 },
65 NotDemonstrated {
67 task: String,
69 },
70 RunFailed {
72 task: Option<String>,
74 },
75 Interrupted {
77 task: String,
79 },
80 Running,
82 NothingReady,
84 Planning {
87 skills: Vec<String>,
89 },
90 ReportShown {
92 ready: usize,
94 open_approvals: usize,
96 stale: usize,
98 },
99 ApprovalsListed {
101 first: Option<String>,
103 },
104 Decided,
106 ChecklistsOpen,
108 ConfigError,
110 GitPrecondition,
112 ArchiveAwaiting {
114 change: String,
116 },
117}
118
119#[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}