1use std::path::Path;
9
10use speccoder_core::actions::{Action, Runnable};
11use speccoder_core::{
12 ApprovalsReport, CommandData, CommandResult, CommandStatus, InboxItem, InboxReport,
13 StatusReport,
14};
15
16use crate::actions::action;
17
18pub const KINDS: [&str; Kind::ALL.len()] = {
22 let mut out = [""; Kind::ALL.len()];
23 let mut i = 0;
24 while i < out.len() {
25 out[i] = Kind::ALL[i].as_str();
26 i += 1;
27 }
28 out
29};
30
31use speccoder_core::inbox::Kind;
32
33#[must_use]
36pub fn inbox(path: &Path, feature: Option<&str>) -> CommandResult {
37 inbox_with(path, feature, crate::worktree::state_dir().as_deref())
38}
39
40#[must_use]
43pub fn inbox_with(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
44 let listed = crate::current::list(path, None);
45 let Some(CommandData::Features(f)) = listed.data() else {
46 return CommandResult::carried("inbox", &listed);
47 };
48 let only = match feature {
50 Some(_) => match crate::current::list(path, feature).data() {
51 Some(CommandData::Features(one)) => one.current.clone(),
52 _ => None,
53 },
54 None => None,
55 };
56 if feature.is_some() && only.is_none() {
57 return CommandResult::carried("inbox", &crate::status::report(path, feature));
58 }
59 let names: Vec<String> = f
60 .features
61 .iter()
62 .map(|e| e.name.clone())
63 .filter(|n| only.as_ref().is_none_or(|o| o == n))
64 .collect();
65 let several = names.len() > 1;
66 let mut items = Vec::new();
67 for name in &names {
68 items.extend(of_feature(path, name, state, several));
69 }
70 if only.is_none()
72 && let Some(s) = state
73 && let Some(project) = crate::format::at(path).detect(path)
74 {
75 let plane = crate::worktree::project_state_dir(s, &project);
76 items.extend(
77 crate::bug::state::all(&plane)
78 .iter()
79 .filter(|b| b.home.kind.native())
80 .filter(|b| !crate::controls::skipped(&project, &b.unit).contains_key(&b.task))
82 .filter_map(|b| bug_item(b, &|c| c)),
83 );
84 }
85 let rank = |k: &str| KINDS.iter().position(|x| *x == k).unwrap_or(KINDS.len());
86 items.sort_by(|a, b| {
87 rank(&a.kind)
88 .cmp(&rank(&b.kind))
89 .then(
90 a.since
91 .unwrap_or(u64::MAX)
92 .cmp(&b.since.unwrap_or(u64::MAX)),
93 )
94 .then(a.feature.cmp(&b.feature))
95 .then(a.task.cmp(&b.task))
96 });
97 let message = summary(&items, names.len());
98 CommandResult::new("inbox", CommandStatus::Ok, &message).with_data(CommandData::Inbox(
99 Box::new(InboxReport {
100 items,
101 features: names.len(),
102 }),
103 ))
104}
105
106fn count(n: usize, one: &str, many: &str) -> String {
108 format!("{n} {}", if n == 1 { one } else { many })
109}
110
111fn counted(kind: &str, n: usize) -> String {
113 let (one, many) = match kind {
114 "unknown" => (
115 "feature whose waiting calls could not be read",
116 "features whose waiting calls could not be read",
117 ),
118 "approval" => ("approval", "approvals"),
119 "approval_batch" => (
120 "run whose calls wait together",
121 "runs whose calls wait together",
122 ),
123 "ruling" => ("ruling", "rulings"),
124 "gaming" => ("suspected shortcut", "suspected shortcuts"),
125 "unverified" => (
126 "feature little was verified in",
127 "features little was verified in",
128 ),
129 "stale" => (
130 "tick the spec changed under",
131 "ticks the spec changed under",
132 ),
133 "question" => ("open question", "open questions"),
134 "ambiguous" => ("scenario read two ways", "scenarios read two ways"),
135 "autopilot_denied" => ("call autopilot refused", "calls autopilot refused"),
136 "review" => ("spec to review", "specs to review"),
137 "revise" => ("change request", "change requests"),
138 "stuck" => ("task that did not pass", "tasks that did not pass"),
139 "yours" => ("step only you can take", "steps only you can take"),
140 "aside" => ("set-aside change", "set-aside changes"),
141 "commit" => ("plan to commit", "plans to commit"),
142 "handoff" => ("branch to hand off", "branches to hand off"),
143 "bug_confirm" => (
144 "bug reproduction to confirm",
145 "bug reproductions to confirm",
146 ),
147 "bug_cannot_reproduce" => (
148 "bug that could not be reproduced",
149 "bugs that could not be reproduced",
150 ),
151 "bug_unchanged_not_holding" => (
152 "bug whose Unchanged statement does not hold",
153 "bugs whose Unchanged statements do not hold",
154 ),
155 "bug_repro_weak" => ("bug reproduction too weak", "bug reproductions too weak"),
156 "bug_already_satisfied" => ("bug already fixed", "bugs already fixed"),
157 k => (k, k),
158 };
159 count(n, one, many)
160}
161
162fn summary(items: &[InboxItem], features: usize) -> String {
164 if items.is_empty() {
165 return format!(
166 "nothing waits for you in {}: the agents can work",
167 count(features, "feature", "features")
168 );
169 }
170 let mut in_features: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
171 in_features.sort_unstable();
172 in_features.dedup();
173 let parts: Vec<String> = KINDS
174 .iter()
175 .filter_map(|k| {
176 let n = items.iter().filter(|i| i.kind == *k).count();
177 (n > 0).then(|| counted(k, n))
178 })
179 .collect();
180 format!(
181 "{} waiting in {}: {}",
182 count(items.len(), "decision", "decisions"),
183 count(in_features.len(), "feature", "features"),
184 parts.join(", ")
185 )
186}
187
188fn of_feature(path: &Path, feature: &str, state: Option<&Path>, several: bool) -> Vec<InboxItem> {
190 let report = crate::status::report(path, Some(feature));
191 let Some(CommandData::Report(r)) = report.data() else {
192 return Vec::new();
193 };
194 let approvals: Option<ApprovalsReport> =
195 state.and_then(
196 |s| match crate::approvals::list_with(path, Some(feature), s).data() {
197 Some(CommandData::Approvals(a)) => Some((**a).clone()),
198 _ => None,
199 },
200 );
201 let flag = |command: String| {
202 if several {
203 format!("{command} --feature {feature}")
204 } else {
205 command
206 }
207 };
208 let item = |kind: &str, task: Option<&str>, title: String, detail: String, actions| InboxItem {
209 kind: kind.to_owned(),
210 group: Kind::named(kind)
211 .map_or("blocked_on_you", |k| k.group().as_str())
212 .to_owned(),
213 feature: feature.to_owned(),
214 task: task.map(str::to_owned),
215 title,
216 detail,
217 since: None,
218 actions,
219 unverified: None,
220 };
221 let mut out = Vec::new();
222 let waiting: Vec<&str> = approvals
223 .iter()
224 .flat_map(|a| &a.pending)
225 .map(|p| p.task.as_str())
226 .collect();
227
228 if let Some(why) = approvals.as_ref().and_then(|a| a.unreadable.as_ref()) {
231 out.push(item(
232 "unknown",
233 None,
234 format!("Calls waiting in {feature} could not be read"),
235 format!("{why} — some may be waiting for you"),
236 Vec::new(),
237 ));
238 }
239
240 let batched_runs: Vec<String> = approvals
243 .iter()
244 .flat_map(|a| &a.batch)
245 .map(|b| b.run.clone())
246 .collect();
247 for b in approvals.iter().flat_map(|a| &a.batch) {
248 let calls: Vec<String> = b.groups.iter().flat_map(|g| g.calls.clone()).collect();
249 let of_run: Vec<&speccoder_core::PendingCall> = approvals
250 .iter()
251 .flat_map(|a| &a.pending)
252 .filter(|p| p.run.as_deref() == Some(b.run.as_str()))
253 .collect();
254 let detail: Vec<String> = b
255 .groups
256 .iter()
257 .map(|g| {
258 let shown: Vec<String> = of_run
259 .iter()
260 .filter(|p| p.object.as_ref().map_or("write", |o| o.class.as_str()) == g.class)
261 .map(|p| {
262 let what = p
263 .object
264 .as_ref()
265 .map_or(p.covers.clone(), |o| o.short.clone());
266 let risk = p.presentation.as_ref().map_or("", |v| v.risk.as_str());
267 let copied = if p.sources.is_empty() {
268 ""
269 } else {
270 ", copied from content"
271 };
272 format!("{what} ({}, {risk}{copied})", p.task)
273 })
274 .collect();
275 format!("{}: {}", g.class, shown.join("; "))
276 })
277 .collect();
278 let decide = |verdict, resume_asking| Runnable::DecideBatch {
279 run: b.run.clone(),
280 verdict,
281 calls: calls.clone(),
282 digest: b.digest.clone(),
283 resume_asking,
284 };
285 let command = |verb: &str| {
286 flag(format!(
287 "speccoder approvals batch {} {verb} --digest {}",
288 b.run, b.digest
289 ))
290 };
291 let yes = action(
292 "next",
293 format!("approve the {} waiting call(s) once", calls.len()),
294 "each listed call runs once; the run keeps waiting at its next call",
295 command("approve"),
296 )
297 .with_run(decide(
298 speccoder_core::actions::BatchVerdict::Approve,
299 false,
300 ));
301 let again = action(
302 "next",
303 "approve once and resume asking",
304 "each listed call runs once, and the run prompts you again",
305 format!("{} --resume-asking", command("approve")),
306 )
307 .with_run(decide(speccoder_core::actions::BatchVerdict::Approve, true));
308 let mut no = action(
309 "next",
310 format!("reject the {} waiting call(s)", calls.len()),
311 "the model is told your reason for each",
312 format!("{} --reason \"…\"", command("reject")),
313 )
314 .with_run(decide(speccoder_core::actions::BatchVerdict::Reject, false));
315 no.input = Some("Why (the model is told)".to_owned());
316 let mut i = item(
317 "approval_batch",
318 None,
319 format!(
320 "{} call(s) wait for you in run {} (its approval budget is used)",
321 calls.len(),
322 b.run
323 ),
324 detail.join(" · "),
325 vec![yes, again, no],
326 );
327 i.since = of_run.iter().filter_map(|p| p.waiting_since).min();
328 out.push(i);
329 }
330
331 for p in approvals
333 .iter()
334 .flat_map(|a| &a.pending)
335 .filter(|p| p.run.as_ref().is_none_or(|r| !batched_runs.contains(r)))
336 {
337 let question = p.kind == "question";
338 let mut yes = action(
339 "next",
340 if question {
341 format!("answer {}", p.task)
342 } else {
343 format!("approve {}", p.task)
344 },
345 if question {
346 "the attempt resumes with your answer".to_owned()
347 } else {
348 format!("allow {} for the task", p.covers)
349 },
350 flag(format!("speccoder task approve {}", p.task)),
351 );
352 yes = yes.with_run(Runnable::Approve {
353 task: p.task.clone(),
354 call: p.id(),
355 digest: p.digest.clone(),
356 });
357 if question {
358 yes.input = Some("Your answer".to_owned());
359 }
360 let mut no = action(
361 "next",
362 format!("reject {}", p.task),
363 "the model is told your reason",
364 flag(format!("speccoder task reject {} --reason \"…\"", p.task)),
365 );
366 no = no.with_run(Runnable::Reject {
367 task: p.task.clone(),
368 call: p.id(),
369 digest: p.digest.clone(),
370 });
371 no.input = Some("Why (the model is told)".to_owned());
372 let mut i = item(
373 "approval",
374 Some(&p.task),
375 if question {
376 format!("{} asks you", p.task)
377 } else {
378 format!("{} waits for your approval", p.task)
379 },
380 p.question
381 .clone()
382 .filter(|_| question)
383 .unwrap_or_else(|| format!("{} — {}", p.covers, p.reason)),
384 vec![yes, no],
385 );
386 i.since = p.waiting_since;
387 out.push(i);
388 }
389
390 let root = Path::new(path);
391 let project_state = state.and_then(|s| {
392 crate::format::at(root)
393 .detect(root)
394 .map(|project| crate::worktree::project_state_dir(s, &project))
395 });
396 for t in &r.tasks {
397 let done = matches!(
398 t.state.as_str(),
399 "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
400 );
401 if done || waiting.contains(&t.id.as_str()) {
402 continue;
403 }
404 if let Some(i) = project_state
405 .as_deref()
406 .and_then(|s| crate::bug::state::load(s, feature, &t.id))
407 .and_then(|b| bug_item(&b, &flag))
408 {
409 out.push(i);
410 continue;
411 }
412 let held = project_state
413 .as_deref()
414 .and_then(|s| crate::strength::held(&crate::strength::dir(s, feature, &t.id)));
415 if let Some(held) = held {
416 let mut rule = action(
417 "task",
418 format!("rule on {}", t.id),
419 "the tests are right: the flagged alternatives are not what the spec allows",
420 flag(format!(
421 "speccoder task rule {} --tests-right --reason \"…\"",
422 t.id
423 )),
424 );
425 rule = rule.with_run(Runnable::Rule { task: t.id.clone() });
426 rule.input = Some("Why the tests are right".to_owned());
427 let findings: Vec<String> = held
428 .findings
429 .iter()
430 .map(|p| format!("{} ({})", p.scenario, p.rationale))
431 .collect();
432 out.push(item(
433 "ruling",
434 Some(&t.id),
435 format!("{}'s tests reject an alternative the spec may allow", t.id),
436 findings.join("; "),
437 vec![rule, nudge(t, &flag), skip(t, &flag)],
438 ));
439 continue;
440 }
441 if t.lane == speccoder_core::lane::Lane::Yours
444 && t.last_attempt
445 .as_ref()
446 .map_or(t.state == "ready", |a| a.outcome == "blocked")
447 {
448 let (title, detail) = match &t.last_attempt {
449 Some(a) => (
450 format!("{} needs you: the agent stopped", t.id),
451 a.reason.clone(),
452 ),
453 None => (format!("{} is yours: {}", t.id, t.title), String::new()),
455 };
456 out.push(item(
457 "yours",
458 Some(&t.id),
459 title,
460 detail,
461 yours_actions(
462 t,
463 &dependents(&r.tasks, &t.id),
464 retry_blocked(r, &waiting),
465 &flag,
466 ),
467 ));
468 continue;
469 }
470 let Some(a) = &t.last_attempt else {
471 continue;
472 };
473 if a.outcome == "suspected_gaming" {
474 out.push(item(
475 "gaming",
476 Some(&t.id),
477 format!("{}: the monitor suspects a shortcut", t.id),
478 a.reason.clone(),
479 vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
480 ));
481 continue;
482 }
483 let mut actions = Vec::new();
484 if t.state == "ready" {
485 let mut again = action(
486 "work",
487 format!("run {} again", t.id),
488 "with what the last attempt left: its facts and your notes",
489 flag(format!("speccoder run --task {}", t.id)),
490 );
491 again = again.with_run(Runnable::Run {
492 task: Some(t.id.clone()),
493 agent: None,
494 });
495 again.blocked = retry_blocked(r, &waiting);
496 let other = try_with(&t.id, a, &flag, crate::acp::agent::on_path);
497 actions.push(again);
498 if let Some(mut other) = other {
499 other.blocked = retry_blocked(r, &waiting);
500 actions.push(other);
501 }
502 }
503 actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
504 out.push(item(
505 "stuck",
506 Some(&t.id),
507 format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
508 a.reason.clone(),
509 actions,
510 ));
511 }
512
513 for t in &r.tasks {
515 let Some(a) = &t.set_aside else { continue };
516 let n = a.files.len();
517 let mut keep = action(
518 "task",
519 format!("keep the changes set aside before {}", t.id),
520 "committed onto the feature's branch as yours",
521 flag(format!("speccoder task restore {}", t.id)),
522 );
523 keep = keep.with_run(Runnable::KeepSetAside { task: t.id.clone() });
524 let mut drop = action(
525 "task",
526 format!("discard the changes set aside before {}", t.id),
527 "deleted; the branch stays as it is",
528 flag(format!("speccoder task drop {}", t.id)),
529 );
530 drop = drop.with_run(Runnable::DropSetAside { task: t.id.clone() });
531 drop.confirm = Some(format!(
532 "Discard the {n} changed file(s) set aside before {}? They are deleted, not kept anywhere.",
533 t.id
534 ));
535 out.push(item(
536 "aside",
537 Some(&t.id),
538 format!("Changes were set aside before {} ran", t.id),
539 format!(
540 "{n} file(s) were changed in the worktree when the run started — your edits, or an interrupted run's: {}",
541 a.files.iter().take(4).cloned().collect::<Vec<_>>().join(", ")
542 + if n > 4 { ", …" } else { "" }
543 ),
544 vec![keep, drop],
545 ));
546 }
547
548 let stale: Vec<String> = r
549 .tasks
550 .iter()
551 .filter_map(|t| {
552 let d = t.drift.as_ref().filter(|d| d.stale())?;
553 let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
554 Some(format!("{} ({})", t.id, ids.join(", ")))
555 })
556 .collect();
557 if !stale.is_empty() {
558 let mut a = action(
559 "next",
560 "re-check against the spec",
561 "convergence assesses the code against the spec as it is now",
562 flag("speccoder run --converge".to_owned()),
563 );
564 a = a.with_run(Runnable::Converge);
565 out.push(item(
566 "stale",
567 None,
568 format!(
569 "the spec changed under {}",
570 count(stale.len(), "tick", "ticks")
571 ),
572 format!("proven against an earlier wording: {}", stale.join(", ")),
573 vec![a],
574 ));
575 }
576
577 if let Some(u) = unverified(r, path) {
579 let dominant = u
580 .counts
581 .first()
582 .map(|c| c.reason.clone())
583 .unwrap_or_default();
584 let mut actions = Vec::new();
585 let checks = action(
586 "next",
587 "show the plan checks",
588 "deterministic checks of the task list: where the plan leaves nothing to show",
589 flag("speccoder doctor".to_owned()),
590 );
591 match dominant.as_str() {
592 "no_scenarios" => {
593 if let Some(skill) = crate::format::at(path).planning().revises {
594 let mut a = action(
595 "plan",
596 "add scenarios",
597 "the format's clarify skill asks for the examples a test is written from",
598 flag(format!("speccoder spec {skill}")),
599 );
600 a = a.with_run(Runnable::Skill {
601 name: skill.to_owned(),
602 arguments: String::new(),
603 });
604 actions.push(a);
605 } else {
606 actions.push(checks.clone());
607 }
608 }
609 "already_passing" | "no_story"
612 if crate::format::at(path).kind() == speccoder_core::spec::FormatKind::Speckit
613 && crate::preset::state(path).installed.is_none() =>
614 {
615 let mut a = action(
616 "plan",
617 "add the speccoder preset",
618 "future task lists keep a story's behaviour inside its story; no spec file changes",
619 "speccoder preset add --yes".to_owned(),
620 );
621 a = a.with_run(Runnable::PresetAdd { update: false });
622 if !crate::preset::speckit_version().is_some_and(|v| v >= crate::preset::NEEDS) {
624 a.blocked =
625 Some("needs Spec Kit's `specify` 1.2.0 or newer on PATH".to_owned());
626 }
627 a.confirm = Some(
628 "Install the speccoder preset with the spec tool's own preset command? It writes the spec tool's preset directory and registry; no spec file changes.".to_owned(),
629 );
630 actions.push(a);
631 }
632 _ => actions.push(checks.clone()),
633 }
634 let mut converge = action(
635 "next",
636 "converge",
637 "assess the code against the spec as it is now and append what remains",
638 flag("speccoder converge".to_owned()),
639 );
640 converge = converge.with_run(Runnable::Converge);
641 actions.push(converge);
642 actions.push(action(
643 "review",
644 "open the report",
645 "each task's evidence and why",
646 flag("speccoder report".to_owned()),
647 ));
648 let mut it = item(
649 "unverified",
650 None,
651 format!(
652 "Little was verified in `{}`: {} of {} ticks are regression-only",
653 r.feature, u.regression_only, u.ticks
654 ),
655 u.counts
656 .iter()
657 .map(|c| format!("{} · {}", c.text, c.tasks.join(" ")))
658 .collect::<Vec<_>>()
659 .join("; "),
660 actions,
661 );
662 it.unverified = Some(u);
663 out.push(it);
664 }
665
666 let denied = project_state
668 .as_deref()
669 .map(|s| crate::oracle::last_denials(s, feature))
670 .unwrap_or_default();
671 if !denied.is_empty() {
672 let read = action(
673 "work",
674 "see what autopilot refused",
675 "each call was refused for that run; approve one when you run again, or allow it in Settings",
676 flag("speccoder run --json".to_owned()),
677 );
678 out.push(item(
679 "autopilot_denied",
680 None,
681 count(
682 denied.len(),
683 "call autopilot refused instead of asking",
684 "calls autopilot refused instead of asking",
685 ),
686 denied
687 .iter()
688 .take(4)
689 .map(|d| format!("{}: {}", d.task, d.object))
690 .collect::<Vec<_>>()
691 .join("; "),
692 vec![read],
693 ));
694 }
695
696 if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
698 let t = rv.totals;
699 if !rv.questions.is_empty() {
701 let read = action(
702 "plan",
703 "answer the open questions",
704 "your words replace each question where it stands",
705 flag("speccoder review".to_owned()),
706 );
707 let mut actions = vec![read];
708 if let Some(skill) = &rv.revise_with {
709 let mut a = action(
710 "plan",
711 format!("clarify with {skill}"),
712 "the format's skill asks you the questions and writes the answers in",
713 flag(format!("speccoder spec {skill}")),
714 );
715 a = a
716 .with_run(Runnable::Skill {
717 name: skill.clone(),
718 arguments: String::new(),
719 })
720 .with_role(crate::format::at(path).planning().role_of(skill));
721 actions.push(a);
722 }
723 out.push(item(
724 "question",
725 None,
726 count(
727 rv.questions.len(),
728 "open question in the spec",
729 "open questions in the spec",
730 ),
731 rv.questions
732 .iter()
733 .take(3)
734 .map(|q| format!("{}: {}", q.id, q.text))
735 .collect::<Vec<_>>()
736 .join("; "),
737 actions,
738 ));
739 }
740 let requirements = match crate::inspect(path, Some(feature)).data() {
742 Some(CommandData::Inspect(snap)) => crate::format::of(snap).oracle_requirements(snap),
743 _ => Vec::new(),
744 };
745 let ambiguous = match project_state
746 .as_deref()
747 .map(|s| crate::oracle::open_ambiguities(s, feature, &requirements))
748 {
749 Some(Ok(a)) => a,
750 None => Vec::new(),
751 Some(Err(t)) => {
753 out.push(item(
754 "ambiguous",
755 None,
756 "a test draft cannot be read".to_owned(),
757 format!("{} — Approve and Run waits until it is written again", t.0),
758 vec![action(
759 "review",
760 "see the tests",
761 "write the requirement's tests again",
762 flag("speccoder review".to_owned()),
763 )],
764 ));
765 Vec::new()
766 }
767 };
768 if let Some((req, scenario)) = ambiguous.first() {
769 let mut a = action(
770 "review",
771 "reading A is meant",
772 "the first author's tests stay",
773 flag(format!(
774 "speccoder review tests {req} --reading A {scenario}"
775 )),
776 );
777 a = a.with_run(Runnable::OracleTests {
778 req: req.clone(),
779 op: speccoder_core::oracle::OracleOp::Reading,
780 path: None,
781 scenario: Some(scenario.clone()),
782 reading: Some(speccoder_core::oracle::Reading::A),
783 });
784 let mut b = action(
785 "review",
786 "reading B is meant",
787 "its tests are written again with that reading",
788 flag(format!(
789 "speccoder review tests {req} --reading B {scenario}"
790 )),
791 );
792 b = b.with_run(Runnable::OracleTests {
793 req: req.clone(),
794 op: speccoder_core::oracle::OracleOp::Reading,
795 path: None,
796 scenario: Some(scenario.clone()),
797 reading: Some(speccoder_core::oracle::Reading::B),
798 });
799 out.push(item(
800 "ambiguous",
801 None,
802 count(
803 ambiguous.len(),
804 "scenario read two ways",
805 "scenarios read two ways",
806 ),
807 ambiguous
808 .iter()
809 .map(|(r, s)| format!("{r}/{s}"))
810 .collect::<Vec<_>>()
811 .join(", ")
812 + " — Approve and Run waits until you choose a reading or reword",
813 vec![a, b],
814 ));
815 }
816 if t.open + t.lapsed > 0 {
817 let pending: Vec<&str> = rv
818 .requirements
819 .iter()
820 .filter(|i| i.state == "open" || i.state == "lapsed")
821 .map(|i| i.id.as_str())
822 .collect();
823 let read = action(
824 "plan",
825 "read the requirements",
826 "each one, as the result will be judged against it",
827 flag("speccoder review".to_owned()),
828 );
829 let mut all = action(
830 "plan",
831 format!(
832 "approve {}",
833 count(pending.len(), "requirement", "requirements")
834 ),
835 "on their current wording, as you",
836 flag("speccoder review approve".to_owned()),
837 );
838 all = all.with_run(Runnable::Review {
839 ids: Vec::new(),
840 verdict: "approve".to_owned(),
841 });
842 out.push(item(
843 "review",
844 None,
845 if pending.len() == 1 {
846 "1 requirement waits for your review".to_owned()
847 } else {
848 format!("{} requirements wait for your review", pending.len())
849 },
850 if t.lapsed > 0 {
851 format!(
852 "{}; {} changed since you decided",
853 pending.join(", "),
854 t.lapsed
855 )
856 } else {
857 pending.join(", ")
858 },
859 vec![read, all],
860 ));
861 }
862 if t.changes_requested > 0 {
863 let asked: Vec<String> = rv
864 .requirements
865 .iter()
866 .filter(|i| i.state == "changes_requested")
867 .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
868 .collect();
869 let mut actions = Vec::new();
870 if let Some(skill) = &rv.revise_with {
871 let mut a = action(
872 "plan",
873 format!("revise the spec ({skill})"),
874 "the format's skill rewrites what you asked; you review the result",
875 flag(format!("speccoder spec {skill} \"…\"")),
876 );
877 a = a
878 .with_run(Runnable::Skill {
879 name: skill.clone(),
880 arguments: rv.revision.clone().unwrap_or_default(),
881 })
882 .with_role(crate::format::at(path).planning().role_of(skill));
883 actions.push(a);
884 }
885 actions.push(action(
886 "plan",
887 "edit the spec yourself",
888 "then approve the new wording",
889 flag("speccoder review".to_owned()),
890 ));
891 out.push(item(
892 "revise",
893 None,
894 format!(
895 "{} on the spec",
896 count(t.changes_requested, "change request", "change requests")
897 ),
898 asked.join("; "),
899 actions,
900 ));
901 }
902 }
903
904 if !r.uncommitted.is_empty() {
905 let mut a = action(
906 "next",
907 format!("commit the planning files ({})", r.uncommitted.len()),
908 "a run reads committed files only",
909 format!(
910 "git add {0} && git commit -m \"spec: …\" -- {0}",
911 r.uncommitted.join(" ")
912 ),
913 );
914 a = a.with_run(Runnable::Commit {
915 paths: r.uncommitted.clone(),
916 });
917 out.push(item(
918 "commit",
919 None,
920 "planning files not committed yet".to_owned(),
921 format!("read them, then commit: {}", r.uncommitted.join(", ")),
922 vec![a],
923 ));
924 }
925
926 if handoff(r) && stale.is_empty() {
927 let mut pr = action(
928 "review",
929 "open a draft pull request",
930 "push the branch and its evidence; open (or update) the draft",
931 flag("speccoder pr --yes".to_owned()),
932 );
933 pr = pr.with_run(Runnable::PullRequest);
934 pr.confirm = Some(
935 "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).".to_owned(),
936 );
937 out.push(item(
938 "handoff",
939 None,
940 format!("every task is done: review {} and hand it off", r.feature),
941 format!(
942 "{} on {} not merged",
943 count(r.unmerged_commits, "commit", "commits"),
944 r.branch.as_deref().unwrap_or_default()
945 ),
946 vec![pr],
947 ));
948 }
949 out
950}
951
952fn unverified(r: &StatusReport, path: &Path) -> Option<speccoder_core::Unverified> {
956 use speccoder_core::ReasonCount;
957 let ticks = r.totals.verified + r.totals.regression_only;
958 let regression = r.totals.regression_only;
959 if ticks < 3 || regression * 2 <= ticks || handed_off(r, path) {
960 return None;
961 }
962 let mut counts: Vec<ReasonCount> = Vec::new();
963 for t in r.tasks.iter().filter(|t| t.state == "regression_only") {
964 let reason = t
965 .regression
966 .as_ref()
967 .map(|g| g.reason.clone())
968 .unwrap_or(speccoder_core::regression::RegressionReason::Unrecorded);
969 let name = reason.name().to_owned();
970 let by = match &reason {
971 speccoder_core::regression::RegressionReason::AlreadyPassing {
972 implemented_by: Some(speccoder_core::regression::ImplementedBy::Task { task, .. }),
973 } => Some(task.clone()),
974 _ => None,
975 };
976 let c = match counts.iter_mut().position(|c| c.reason == name) {
977 Some(i) => &mut counts[i],
978 None => {
979 counts.push(ReasonCount {
980 reason: name,
981 n: 0,
982 tasks: Vec::new(),
983 implemented_by: Vec::new(),
984 text: String::new(),
985 });
986 let last = counts.len() - 1;
987 &mut counts[last]
988 }
989 };
990 c.n += 1;
991 c.tasks.push(t.id.clone());
992 if let Some(b) = by.filter(|b| !c.implemented_by.contains(b)) {
993 c.implemented_by.push(b);
994 }
995 }
996 counts.sort_by(|a, b| b.n.cmp(&a.n).then(a.reason.cmp(&b.reason)));
997 for c in &mut counts {
998 c.text = reason_count_words(c);
999 }
1000 Some(speccoder_core::Unverified {
1001 ticks,
1002 regression_only: regression,
1003 counts,
1004 })
1005}
1006
1007fn reason_count_words(c: &speccoder_core::ReasonCount) -> String {
1009 let n = c.n;
1010 let were = if n == 1 { "was" } else { "were" };
1011 let what = match c.reason.as_str() {
1012 "no_story" => "had no user story".to_owned(),
1013 "no_scenarios" => "had a story with no acceptance scenario".to_owned(),
1014 "acceptance_off" => format!("{were} ticked with acceptance tests off"),
1015 "author_wrote_none" => "got no test from the test author".to_owned(),
1016 "author_failed" => "had acceptance tests that could not be used".to_owned(),
1017 "already_passing" if c.implemented_by.is_empty() => format!("{were} already passing"),
1018 "already_passing" => format!(
1019 "{were} already passing — implemented by {}",
1020 c.implemented_by.join(", ")
1021 ),
1022 "not_red" => "had acceptance tests that did not fail first".to_owned(),
1023 "tests_not_collected" => "had acceptance tests that never ran".to_owned(),
1024 "planning_work" => format!("{were} planning work (design documents)"),
1025 _ => format!("{were} ticked before reasons were recorded"),
1026 };
1027 format!("{n} {what}")
1028}
1029
1030fn handed_off(r: &StatusReport, path: &Path) -> bool {
1033 let merged = !r.tasks.is_empty()
1034 && r.totals.ready == 0
1035 && r.totals.blocked == 0
1036 && r.unmerged_commits == 0
1037 && r.branch.is_some();
1038 let pushed = r.branch.as_deref().is_some_and(|b| {
1039 crate::git::Git::at(path)
1040 .line(&[
1041 "for-each-ref",
1042 "--format=%(refname)",
1043 &format!("refs/remotes/*/{b}"),
1044 ])
1045 .is_ok_and(|l| !l.trim().is_empty())
1046 });
1047 merged || pushed
1048}
1049
1050fn handoff(r: &StatusReport) -> bool {
1052 !r.tasks.is_empty()
1053 && r.totals.ready == 0
1054 && r.totals.blocked == 0
1055 && r.unmerged_commits > 0
1056 && r.branch.is_some()
1057}
1058
1059fn with_input(
1060 t: &speccoder_core::TaskStatus,
1061 verb: &str,
1062 why: &str,
1063 prompt: &str,
1064 run: Runnable,
1065 flag: &dyn Fn(String) -> String,
1066) -> Action {
1067 let option = if verb == "nudge" {
1068 "--note"
1069 } else {
1070 "--reason"
1071 };
1072 let mut a = action(
1073 "task",
1074 format!("{verb} {}", t.id),
1075 why,
1076 flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
1077 );
1078 a = a.with_run(run);
1079 a.input = Some(prompt.to_owned());
1080 a
1081}
1082
1083fn bug_item(
1085 b: &speccoder_core::bug::BugReport,
1086 flag: &dyn Fn(String) -> String,
1087) -> Option<InboxItem> {
1088 use speccoder_core::bug::Outcome;
1089 let key = b.key();
1090 let kind = match b.outcome {
1091 Outcome::AwaitingConfirmation => Kind::BugConfirm,
1092 Outcome::CannotReproduce => Kind::BugCannotReproduce,
1093 Outcome::UnchangedNotHolding => Kind::BugUnchangedNotHolding,
1094 Outcome::AlreadySatisfied if b.commit.is_some() => return None,
1096 Outcome::AlreadySatisfied => Kind::BugAlreadySatisfied,
1097 _ if b.code.as_deref() == Some("BUG-REPRO-WEAK") => Kind::BugReproWeak,
1098 _ => return None,
1099 };
1100 let task = || b.task.clone();
1101 let mut actions = Vec::new();
1102 match kind {
1103 Kind::BugConfirm => {
1104 let digest = b.confirm_digest.clone().unwrap_or_default();
1105 let mut yes = action(
1106 "next",
1107 format!("confirm {}'s reproductions", b.task),
1108 "they show the bug: they are frozen and the fix runs",
1109 format!("speccoder bug confirm {key} --digest {digest}"),
1110 );
1111 yes = yes.with_run(Runnable::BugConfirm {
1112 task: key.clone(),
1113 digest: digest.clone(),
1114 reject: false,
1115 });
1116 let mut no = action(
1117 "next",
1118 format!("reject {}'s reproductions", b.task),
1119 "the author tries again, told why",
1120 format!("speccoder bug confirm {key} --reject --reason \"…\""),
1121 );
1122 no = no.with_run(Runnable::BugConfirm {
1123 task: key.clone(),
1124 digest,
1125 reject: true,
1126 });
1127 no.input = Some("Why they do not show the bug".to_owned());
1128 actions.extend([yes, no]);
1129 }
1130 Kind::BugAlreadySatisfied => {
1131 let mut keep = action(
1132 "next",
1133 format!("keep {}'s reproduction as a regression test", b.task),
1134 "the reproduction is committed and the task accepted on your word: attested, not verified",
1135 format!("speccoder bug keep {key} --reason \"kept as a regression test\""),
1136 );
1137 keep = keep.with_run(Runnable::BugKeep { task: key.clone() });
1138 keep.input = Some("Why it is kept".to_owned());
1139 let mut drop = action(
1140 "task",
1141 format!("drop {}", b.task),
1142 "it is fixed already",
1143 flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1144 );
1145 drop = drop.with_run(Runnable::Skip { task: task() });
1146 drop.input = Some("Why it is dropped".to_owned());
1147 actions.extend([keep, drop]);
1148 }
1149 Kind::BugReproWeak => {
1150 let mut rule = action(
1151 "task",
1152 format!("rule on {}", b.task),
1153 "the tests are right: the special case is not a fix",
1154 flag(format!(
1155 "speccoder task rule {} --tests-right --reason \"…\"",
1156 b.task
1157 )),
1158 );
1159 rule = rule.with_run(Runnable::Rule { task: task() });
1160 rule.input = Some("Why the tests are right".to_owned());
1161 actions.push(rule);
1162 actions.push(action(
1163 "task",
1164 "bring your own test",
1165 "one per Expected statement",
1166 format!(
1167 "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1168 b.triad.current.text
1169 ),
1170 ));
1171 }
1172 _ => {
1173 let mut again = action(
1174 "next",
1175 format!("reproduce {} again", b.task),
1176 "a fresh set of tries",
1177 format!("speccoder bug run --task {key}"),
1178 );
1179 again = again.with_run(Runnable::BugRun {
1180 slug: None,
1181 task: Some(key.clone()),
1182 });
1183 actions.push(again);
1184 actions.push(action(
1185 "task",
1186 "bring your own test",
1187 "one per Expected statement",
1188 format!(
1189 "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1190 b.triad.current.text
1191 ),
1192 ));
1193 if !b.home.kind.native() {
1194 actions.push(action(
1195 "task",
1196 format!("reword {}", b.requirement),
1197 "the triad in other words (what was confirmed lapses)",
1198 format!(
1199 "speccoder review edit {} \"…\" --feature {}",
1200 b.requirement, b.unit
1201 ),
1202 ));
1203 let mut drop = action(
1204 "task",
1205 format!("drop {}", b.task),
1206 "nothing is fixed",
1207 flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1208 );
1209 drop = drop.with_run(Runnable::Skip { task: task() });
1210 drop.input = Some("Why it is dropped".to_owned());
1211 actions.push(drop);
1212 }
1213 }
1214 }
1215 let detail = b
1216 .handoff
1217 .as_ref()
1218 .map(|h| {
1219 format!(
1220 "{} tries; reproduced: {}; not reproduced: {}; already passing: {}",
1221 h.tries.len(),
1222 if h.statements.reproduced.is_empty() {
1223 "none".to_owned()
1224 } else {
1225 h.statements.reproduced.join(", ")
1226 },
1227 if h.statements.not_reproduced.is_empty() {
1228 "none".to_owned()
1229 } else {
1230 h.statements.not_reproduced.join(", ")
1231 },
1232 if h.statements.satisfied.is_empty() {
1233 "none".to_owned()
1234 } else {
1235 h.statements.satisfied.join(", ")
1236 },
1237 )
1238 })
1239 .unwrap_or_else(|| format!("{} — {}", b.title, b.outcome.as_str().replace('_', " ")));
1240 Some(InboxItem {
1241 unverified: None,
1242 kind: kind.as_str().to_owned(),
1243 group: kind.group().as_str().to_owned(),
1244 feature: b.unit.clone(),
1245 task: Some(b.task.clone()),
1246 title: match kind {
1247 Kind::BugConfirm => format!(
1248 "{}: {} reproduction(s) wait for your confirmation",
1249 b.task,
1250 b.reproductions.iter().filter(|r| r.reproduces()).count()
1251 ),
1252 Kind::BugAlreadySatisfied => format!(
1253 "{}: already fixed — keep the reproduction or drop it",
1254 b.task
1255 ),
1256 Kind::BugReproWeak => {
1257 format!("{}: a special-case fix passes the reproductions", b.task)
1258 }
1259 Kind::BugUnchangedNotHolding => {
1260 format!("{}: an Unchanged statement does not hold today", b.task)
1261 }
1262 _ => format!("{}: could not be reproduced", b.task),
1263 },
1264 detail,
1265 since: b.confirmation.as_ref().map(|c| c.at),
1266 actions,
1267 })
1268}
1269
1270fn retry_blocked(r: &StatusReport, waiting: &[&str]) -> Option<String> {
1273 if !r.uncommitted.is_empty() {
1274 Some("commit the planning files first".to_owned())
1275 } else {
1276 waiting
1277 .first()
1278 .map(|w| format!("{w} waits for your decision and holds the worktree: decide it first"))
1279 }
1280}
1281
1282fn dependents(tasks: &[speccoder_core::TaskStatus], id: &str) -> Vec<String> {
1284 let mut waiting: Vec<&str> = vec![id];
1285 loop {
1286 let more: Vec<&str> = tasks
1287 .iter()
1288 .filter(|t| !waiting.contains(&t.id.as_str()))
1289 .filter(|t| t.blocked_by.iter().any(|b| waiting.contains(&b.as_str())))
1290 .map(|t| t.id.as_str())
1291 .collect();
1292 if more.is_empty() {
1293 break;
1294 }
1295 waiting.extend(more);
1296 }
1297 tasks
1298 .iter()
1299 .filter(|t| t.id != id && waiting.contains(&t.id.as_str()))
1300 .map(|t| t.id.clone())
1301 .collect()
1302}
1303
1304fn yours_actions(
1307 t: &speccoder_core::TaskStatus,
1308 waiting: &[String],
1309 retry: Option<String>,
1310 flag: &dyn Fn(String) -> String,
1311) -> Vec<Action> {
1312 let mut done = action(
1313 "task",
1314 format!("I did it: {}", t.id),
1315 "tick it on your word (attested, never shown as verified)",
1316 flag(format!("speccoder task done {}", t.id)),
1317 );
1318 done = done.with_run(Runnable::Done { task: t.id.clone() });
1319 let mut hint = action(
1320 "task",
1321 format!("hint & retry {}", t.id),
1322 "a note the agent reads, then a new attempt now",
1323 flag(format!("speccoder task hint {} \"…\"", t.id)),
1324 );
1325 hint = hint.with_run(Runnable::Hint { task: t.id.clone() });
1326 hint.input = Some("The hint".to_owned());
1327 hint.blocked = retry;
1328 let mut skip = skip(t, flag);
1329 if !waiting.is_empty() {
1330 let stay = if waiting.len() == 1 { "stays" } else { "stay" };
1331 skip.why = format!("runs pass it by; {} {stay} blocked", waiting.join(", "));
1332 }
1333 vec![done, hint, skip]
1334}
1335
1336fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1337 with_input(
1338 t,
1339 "nudge",
1340 "a note its next attempts read",
1341 "The note",
1342 Runnable::Nudge { task: t.id.clone() },
1343 flag,
1344 )
1345}
1346
1347fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1348 with_input(
1349 t,
1350 "skip",
1351 "runs pass it by; what depends on it stays blocked",
1352 "Why it is skipped",
1353 Runnable::Skip { task: t.id.clone() },
1354 flag,
1355 )
1356}
1357
1358fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1359 with_input(
1360 t,
1361 "accept",
1362 "tick it on your word (attested, never shown as verified)",
1363 "Why it is done",
1364 Runnable::Accept { task: t.id.clone() },
1365 flag,
1366 )
1367}
1368
1369fn try_with(
1373 task: &str,
1374 last: &speccoder_core::LastAttempt,
1375 flag: &dyn Fn(String) -> String,
1376 on_path: impl Fn(&str) -> bool,
1377) -> Option<Action> {
1378 let current = last
1379 .run_id
1380 .as_deref()
1381 .and_then(|r| r.strip_prefix("external_"))
1382 .and_then(|r| r.split('_').next())
1383 .filter(|a| *a != "command")?;
1384 let next = crate::acp::ready::next_after(Some(current), &on_path)?;
1385 Some(
1386 action(
1387 "work",
1388 format!("try {task} with {}", next.name),
1389 "the same task, another agent: one attempt, judged the same way",
1390 flag(format!("speccoder run --task {task} --agent {}", next.id)),
1391 )
1392 .with_run(Runnable::Run {
1393 task: Some(task.to_owned()),
1394 agent: Some(next.id.to_owned()),
1395 }),
1396 )
1397}
1398
1399#[cfg(test)]
1400mod try_with_tests {
1401 use super::*;
1402
1403 #[test]
1404 fn a_failed_agent_attempt_offers_the_next_agent_and_a_native_one_does_not() {
1405 let t = "T005";
1406 let last = |run: &str| speccoder_core::LastAttempt {
1407 outcome: "verification_failed".to_owned(),
1408 reason: String::new(),
1409 run_id: Some(run.to_owned()),
1410 reference: String::new(),
1411 };
1412 let flag = |c: String| c;
1413 let both = |x: &str| x == "copilot" || x == "opencode";
1414 let a = try_with(t, &last("external_copilot_1"), &flag, both)
1415 .unwrap_or_else(|| panic!("offered"));
1416 assert_eq!(a.command, "speccoder run --task T005 --agent opencode");
1417 assert_eq!(
1418 a.run,
1419 Some(Runnable::Run {
1420 task: Some("T005".to_owned()),
1421 agent: Some("opencode".to_owned())
1422 })
1423 );
1424 assert!(try_with(t, &last("run_01ABC"), &flag, both).is_none());
1425 assert!(try_with(t, &last("external_command_1"), &flag, both).is_none());
1426 let alone = |x: &str| x == "copilot";
1427 assert!(try_with(t, &last("external_copilot_1"), &flag, alone).is_none());
1428 }
1429}