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