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