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 let undecided = |c: &&String| {
509 state.is_none_or(|s| {
510 crate::acceptance_at::deferred(
511 &crate::worktree::project_state_dir(s, path),
512 feature,
513 &t.id,
514 )
515 .is_none()
516 }) && !c.is_empty()
517 };
518 let defer_offer = t
519 .last_attempt
520 .as_ref()
521 .and_then(|a| a.defer_to.as_ref())
522 .filter(undecided)
523 .map(|at| {
524 action(
525 "decide",
526 format!("judge it at {at}"),
527 format!("its acceptance tests need later tasks of the story: {} is judged by its regression checks, its scenarios at {at}", t.id),
528 flag(format!("speccoder task defer {}", t.id)),
529 )
530 .with_run(Runnable::DeferAcceptance {
531 task: t.id.clone(),
532 revoke: false,
533 })
534 });
535 if t.lane == speccoder_core::lane::Lane::Yours
538 && t.last_attempt
539 .as_ref()
540 .map_or(t.state == "ready", |a| a.outcome == "blocked")
541 {
542 let (title, detail) = match &t.last_attempt {
543 Some(a) => (
544 format!("{} needs you: the agent stopped", t.id),
545 a.reason.clone(),
546 ),
547 None => (format!("{} is yours: {}", t.id, t.title), String::new()),
549 };
550 out.push(item(
551 "yours",
552 Some(&t.id),
553 title,
554 detail,
555 defer_offer
556 .clone()
557 .into_iter()
558 .chain(yours_actions(
559 t,
560 &dependents(&r.tasks, &t.id),
561 retry_blocked(r, &waiting),
562 &flag,
563 ))
564 .collect(),
565 ));
566 continue;
567 }
568 let Some(a) = &t.last_attempt else {
569 continue;
570 };
571 if a.outcome == "suspected_gaming" {
572 out.push(item(
573 "gaming",
574 Some(&t.id),
575 format!("{}: the monitor suspects a shortcut", t.id),
576 a.reason.clone(),
577 vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
578 ));
579 continue;
580 }
581 let mut actions: Vec<_> = defer_offer.into_iter().collect();
582 if t.state == "ready" {
583 let mut again = action(
584 "work",
585 format!("run {} again", t.id),
586 "with what the last attempt left: its facts and your notes",
587 flag(format!("speccoder run --task {}", t.id)),
588 );
589 again = again.with_run(Runnable::Run {
590 task: Some(t.id.clone()),
591 agent: None,
592 });
593 again.blocked = retry_blocked(r, &waiting);
594 let other = try_with(&t.id, a, &flag, crate::acp::agent::on_path);
595 actions.push(again);
596 if let Some(mut other) = other {
597 other.blocked = retry_blocked(r, &waiting);
598 actions.push(other);
599 }
600 }
601 actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
602 out.push(item(
603 "stuck",
604 Some(&t.id),
605 format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
606 a.reason.clone(),
607 actions,
608 ));
609 }
610
611 for t in &r.tasks {
613 let Some(a) = &t.set_aside else { continue };
614 let n = a.files.len();
615 let mut keep = action(
616 "task",
617 format!("keep the changes set aside before {}", t.id),
618 "committed onto the feature's branch as yours",
619 flag(format!("speccoder task restore {}", t.id)),
620 );
621 keep = keep.with_run(Runnable::KeepSetAside { task: t.id.clone() });
622 let mut drop = action(
623 "task",
624 format!("discard the changes set aside before {}", t.id),
625 "deleted; the branch stays as it is",
626 flag(format!("speccoder task drop {}", t.id)),
627 );
628 drop = drop.with_run(Runnable::DropSetAside { task: t.id.clone() });
629 drop.confirm = Some(format!(
630 "Discard the {n} changed file(s) set aside before {}? They are deleted, not kept anywhere.",
631 t.id
632 ));
633 out.push(item(
634 "aside",
635 Some(&t.id),
636 format!("Changes were set aside before {} ran", t.id),
637 format!(
638 "{n} file(s) were changed in the worktree when the run started — your edits, or an interrupted run's: {}",
639 a.files.iter().take(4).cloned().collect::<Vec<_>>().join(", ")
640 + if n > 4 { ", …" } else { "" }
641 ),
642 vec![keep, drop],
643 ));
644 }
645
646 if let Some(state) = state {
649 let plane = crate::worktree::project_state_dir(state, path);
650 for o in crate::ladder::offers(path, &plane, feature) {
651 out.push(promote_item(&plane, &o, |t, d, a| {
652 item("promote", Some(&o.task), t, d, a)
653 }));
654 }
655 }
656
657 let stale: Vec<String> = r
658 .tasks
659 .iter()
660 .filter_map(|t| {
661 let d = t.drift.as_ref().filter(|d| d.stale())?;
662 let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
663 Some(format!("{} ({})", t.id, ids.join(", ")))
664 })
665 .collect();
666 if !stale.is_empty() {
667 let mut a = action(
668 "next",
669 "re-check against the spec",
670 "convergence assesses the code against the spec as it is now",
671 flag("speccoder run --converge".to_owned()),
672 );
673 a = a.with_run(Runnable::Converge);
674 out.push(item(
675 "stale",
676 None,
677 format!(
678 "the spec changed under {}",
679 count(stale.len(), "tick", "ticks")
680 ),
681 format!("proven against an earlier wording: {}", stale.join(", ")),
682 vec![a],
683 ));
684 }
685
686 if let Some(u) = unverified(r, path) {
688 let dominant = u
689 .counts
690 .first()
691 .map(|c| c.reason.clone())
692 .unwrap_or_default();
693 let mut actions = Vec::new();
694 let checks = action(
695 "next",
696 "show the plan checks",
697 "deterministic checks of the task list: where the plan leaves nothing to show",
698 flag("speccoder doctor".to_owned()),
699 );
700 match dominant.as_str() {
701 "no_scenarios" => {
702 if let Some(skill) = crate::format::at(path).planning().revises {
703 let mut a = action(
704 "plan",
705 "add scenarios",
706 "the format's clarify skill asks for the examples a test is written from",
707 flag(format!("speccoder spec {skill}")),
708 );
709 a = a.with_run(Runnable::Skill {
710 name: skill.to_owned(),
711 arguments: String::new(),
712 });
713 actions.push(a);
714 } else {
715 actions.push(checks.clone());
716 }
717 }
718 "already_passing" | "no_story"
721 if crate::format::at(path).kind() == speccoder_core::spec::FormatKind::Speckit
722 && crate::preset::state(path).installed.is_none() =>
723 {
724 let mut a = action(
725 "plan",
726 "add the speccoder preset",
727 "future task lists keep a story's behaviour inside its story; no spec file changes",
728 "speccoder preset add --yes".to_owned(),
729 );
730 a = a.with_run(Runnable::PresetAdd { update: false });
731 if !crate::preset::speckit_version().is_some_and(|v| v >= crate::preset::NEEDS) {
733 a.blocked =
734 Some("needs Spec Kit's `specify` 1.2.0 or newer on PATH".to_owned());
735 }
736 a.confirm = Some(
737 "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(),
738 );
739 actions.push(a);
740 }
741 _ => actions.push(checks.clone()),
742 }
743 let mut converge = action(
744 "next",
745 "converge",
746 "assess the code against the spec as it is now and append what remains",
747 flag("speccoder converge".to_owned()),
748 );
749 converge = converge.with_run(Runnable::Converge);
750 actions.push(converge);
751 actions.push(action(
752 "review",
753 "open the report",
754 "each task's evidence and why",
755 flag("speccoder report".to_owned()),
756 ));
757 let mut it = item(
758 "unverified",
759 None,
760 format!(
761 "Little was verified in `{}`: {} of {} ticks are regression-only",
762 r.feature, u.regression_only, u.ticks
763 ),
764 u.counts
765 .iter()
766 .map(|c| format!("{} · {}", c.text, c.tasks.join(" ")))
767 .collect::<Vec<_>>()
768 .join("; "),
769 actions,
770 );
771 it.unverified = Some(u);
772 out.push(it);
773 }
774
775 let denied = project_state
777 .as_deref()
778 .map(|s| crate::oracle::last_denials(s, feature))
779 .unwrap_or_default();
780 if !denied.is_empty() {
781 let read = action(
782 "work",
783 "see what autopilot refused",
784 "each call was refused for that run; approve one when you run again, or allow it in Settings",
785 flag("speccoder run --json".to_owned()),
786 );
787 out.push(item(
788 "autopilot_denied",
789 None,
790 count(
791 denied.len(),
792 "call autopilot refused instead of asking",
793 "calls autopilot refused instead of asking",
794 ),
795 denied
796 .iter()
797 .take(4)
798 .map(|d| format!("{}: {}", d.task, d.object))
799 .collect::<Vec<_>>()
800 .join("; "),
801 vec![read],
802 ));
803 }
804
805 if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
807 let t = rv.totals;
808 if !rv.questions.is_empty() {
810 let read = action(
811 "plan",
812 "answer the open questions",
813 "your words replace each question where it stands",
814 flag("speccoder review".to_owned()),
815 );
816 let mut actions = vec![read];
817 if let Some(skill) = &rv.revise_with {
818 let mut a = action(
819 "plan",
820 format!("clarify with {skill}"),
821 "the format's skill asks you the questions and writes the answers in",
822 flag(format!("speccoder spec {skill}")),
823 );
824 a = a
825 .with_run(Runnable::Skill {
826 name: skill.clone(),
827 arguments: String::new(),
828 })
829 .with_role(crate::format::at(path).planning().role_of(skill));
830 actions.push(a);
831 }
832 out.push(item(
833 "question",
834 None,
835 count(
836 rv.questions.len(),
837 "open question in the spec",
838 "open questions in the spec",
839 ),
840 rv.questions
841 .iter()
842 .take(3)
843 .map(|q| format!("{}: {}", q.id, q.text))
844 .collect::<Vec<_>>()
845 .join("; "),
846 actions,
847 ));
848 }
849 let requirements = match crate::inspect(path, Some(feature)).data() {
851 Some(CommandData::Inspect(snap)) => crate::format::of(snap).oracle_requirements(snap),
852 _ => Vec::new(),
853 };
854 let ambiguous = match project_state
855 .as_deref()
856 .map(|s| crate::oracle::open_ambiguities(s, feature, &requirements))
857 {
858 Some(Ok(a)) => a,
859 None => Vec::new(),
860 Some(Err(t)) => {
862 out.push(item(
863 "ambiguous",
864 None,
865 "a test draft cannot be read".to_owned(),
866 format!("{} — Approve and Run waits until it is written again", t.0),
867 vec![action(
868 "review",
869 "see the tests",
870 "write the requirement's tests again",
871 flag("speccoder review".to_owned()),
872 )],
873 ));
874 Vec::new()
875 }
876 };
877 if let Some((req, scenario)) = ambiguous.first() {
878 let mut a = action(
879 "review",
880 "reading A is meant",
881 "the first author's tests stay",
882 flag(format!(
883 "speccoder review tests {req} --reading A {scenario}"
884 )),
885 );
886 a = a.with_run(Runnable::OracleTests {
887 req: req.clone(),
888 op: speccoder_core::oracle::OracleOp::Reading,
889 path: None,
890 scenario: Some(scenario.clone()),
891 reading: Some(speccoder_core::oracle::Reading::A),
892 });
893 let mut b = action(
894 "review",
895 "reading B is meant",
896 "its tests are written again with that reading",
897 flag(format!(
898 "speccoder review tests {req} --reading B {scenario}"
899 )),
900 );
901 b = b.with_run(Runnable::OracleTests {
902 req: req.clone(),
903 op: speccoder_core::oracle::OracleOp::Reading,
904 path: None,
905 scenario: Some(scenario.clone()),
906 reading: Some(speccoder_core::oracle::Reading::B),
907 });
908 out.push(item(
909 "ambiguous",
910 None,
911 count(
912 ambiguous.len(),
913 "scenario read two ways",
914 "scenarios read two ways",
915 ),
916 ambiguous
917 .iter()
918 .map(|(r, s)| format!("{r}/{s}"))
919 .collect::<Vec<_>>()
920 .join(", ")
921 + " — Approve and Run waits until you choose a reading or reword",
922 vec![a, b],
923 ));
924 }
925 if t.open + t.lapsed > 0 {
926 let pending: Vec<&str> = rv
927 .requirements
928 .iter()
929 .filter(|i| i.state == "open" || i.state == "lapsed")
930 .map(|i| i.id.as_str())
931 .collect();
932 let read = action(
933 "plan",
934 "read the requirements",
935 "each one, as the result will be judged against it",
936 flag("speccoder review".to_owned()),
937 );
938 let mut all = action(
939 "plan",
940 format!(
941 "approve {}",
942 count(pending.len(), "requirement", "requirements")
943 ),
944 "on their current wording, as you",
945 flag("speccoder review approve".to_owned()),
946 );
947 all = all.with_run(Runnable::Review {
948 ids: Vec::new(),
949 verdict: "approve".to_owned(),
950 });
951 out.push(item(
952 "review",
953 None,
954 if pending.len() == 1 {
955 "1 requirement waits for your review".to_owned()
956 } else {
957 format!("{} requirements wait for your review", pending.len())
958 },
959 if t.lapsed > 0 {
960 format!(
961 "{}; {} changed since you decided",
962 pending.join(", "),
963 t.lapsed
964 )
965 } else {
966 pending.join(", ")
967 },
968 vec![read, all],
969 ));
970 }
971 if t.changes_requested > 0 {
972 let asked: Vec<String> = rv
973 .requirements
974 .iter()
975 .filter(|i| i.state == "changes_requested")
976 .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
977 .collect();
978 let mut actions = Vec::new();
979 if let Some(skill) = &rv.revise_with {
980 let mut a = action(
981 "plan",
982 format!("revise the spec ({skill})"),
983 "the format's skill rewrites what you asked; you review the result",
984 flag(format!("speccoder spec {skill} \"…\"")),
985 );
986 a = a
987 .with_run(Runnable::Skill {
988 name: skill.clone(),
989 arguments: rv.revision.clone().unwrap_or_default(),
990 })
991 .with_role(crate::format::at(path).planning().role_of(skill));
992 actions.push(a);
993 }
994 actions.push(action(
995 "plan",
996 "edit the spec yourself",
997 "then approve the new wording",
998 flag("speccoder review".to_owned()),
999 ));
1000 out.push(item(
1001 "revise",
1002 None,
1003 format!(
1004 "{} on the spec",
1005 count(t.changes_requested, "change request", "change requests")
1006 ),
1007 asked.join("; "),
1008 actions,
1009 ));
1010 }
1011 }
1012
1013 if !r.uncommitted.is_empty() {
1014 let mut a = action(
1015 "next",
1016 format!("commit the planning files ({})", r.uncommitted.len()),
1017 "a run reads committed files only",
1018 format!(
1019 "git add {0} && git commit -m \"spec: …\" -- {0}",
1020 r.uncommitted.join(" ")
1021 ),
1022 );
1023 a = a.with_run(Runnable::Commit {
1024 paths: r.uncommitted.clone(),
1025 });
1026 out.push(item(
1027 "commit",
1028 None,
1029 "planning files not committed yet".to_owned(),
1030 format!("read them, then commit: {}", r.uncommitted.join(", ")),
1031 vec![a],
1032 ));
1033 }
1034
1035 if handoff(r) && stale.is_empty() {
1036 let mut pr = action(
1037 "review",
1038 "open a draft pull request",
1039 "push the branch and its evidence; open (or update) the draft",
1040 flag("speccoder pr --yes".to_owned()),
1041 );
1042 pr = pr.with_run(Runnable::PullRequest);
1043 pr.confirm = Some(
1044 "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(),
1045 );
1046 out.push(item(
1047 "handoff",
1048 None,
1049 format!("every task is done: review {} and hand it off", r.feature),
1050 format!(
1051 "{} on {} not merged",
1052 count(r.unmerged_commits, "commit", "commits"),
1053 r.branch.as_deref().unwrap_or_default()
1054 ),
1055 vec![pr],
1056 ));
1057 }
1058 if out.iter().any(|i| EVIDENCED.contains(&i.kind.as_str())) {
1059 let risk = risk_of(path, r);
1060 for i in out
1061 .iter_mut()
1062 .filter(|i| EVIDENCED.contains(&i.kind.as_str()))
1063 {
1064 i.risk.clone_from(&risk);
1065 }
1066 }
1067 out
1068}
1069
1070fn promote_item(
1073 plane: &Path,
1074 o: &crate::ladder::Offer,
1075 item: impl Fn(String, String, Vec<Action>) -> InboxItem,
1076) -> InboxItem {
1077 let at = format!("{}/{}", o.unit, o.task);
1078 let promote = action(
1079 "review",
1080 format!("start a spec from {}", o.task),
1081 "its requirement, word for word, becomes a spec of its own; the earlier tick is inherited, not verified",
1082 format!("speccoder spec promote {at}"),
1083 )
1084 .with_run(crate::ladder::promotion(o, crate::ladder::suggested_by(plane, o)));
1085 let not_now = action(
1086 "review",
1087 "not now",
1088 "offered again only when another signal holds",
1089 format!("speccoder spec promote {at} --decline"),
1090 )
1091 .with_run(Runnable::DeclinePromotion { task: at });
1092 item(
1093 format!(
1094 "{} grew past a {}",
1095 o.task,
1096 if o.bug { "bug fix" } else { "quick change" }
1097 ),
1098 o.signals
1099 .iter()
1100 .map(|s| s.text.clone())
1101 .collect::<Vec<_>>()
1102 .join(" · "),
1103 vec![promote, not_now],
1104 )
1105}
1106
1107const EVIDENCED: [&str; 3] = ["handoff", "unverified", "stale"];
1109
1110fn risk_of(path: &Path, r: &speccoder_core::StatusReport) -> Vec<speccoder_core::RiskReason> {
1114 use speccoder_core::inbox::Risk;
1115 let target = crate::oracle::ViewConfig::at(path)
1116 .strength_target
1117 .map(|t| f64::from(t) / 100.0);
1118 let mut n = [0usize; Risk::ALL.len()];
1119 let mut bump = |risk: Risk| n[Risk::ALL.iter().position(|x| *x == risk).unwrap_or(0)] += 1;
1120 for t in &r.tasks {
1121 match t.state.as_str() {
1122 "regression_only" => bump(Risk::RegressionOnly),
1123 "attested" => bump(Risk::Attested),
1124 _ => {}
1125 }
1126 if t.drift.is_some() {
1127 bump(Risk::Stale);
1128 }
1129 let strength = t
1130 .strength
1131 .as_deref()
1132 .and_then(|s| s.split_whitespace().next())
1133 .and_then(|v| v.parse::<f64>().ok());
1134 if t.state == "verified"
1135 && let (Some(s), Some(min)) = (strength, target)
1136 && s < min
1137 {
1138 bump(Risk::StrengthBelowTarget);
1139 }
1140 }
1141 let commits: Vec<&str> = r.tasks.iter().filter_map(|t| t.commit.as_deref()).collect();
1142 if !commits.is_empty() {
1143 let mut args = vec![
1146 "log",
1147 "--no-walk=unsorted",
1148 "--ignore-missing",
1149 "--format=%(trailers:key=Scope-Outside,key=Executor,separator=%x01)%x1e",
1150 ];
1151 args.extend(commits.iter().copied());
1152 if let Ok(out) = crate::git::Git::at(path).run(&args) {
1153 for block in String::from_utf8_lossy(&out).split('\u{1e}') {
1154 if block.contains("Scope-Outside:") {
1155 bump(Risk::ScopeOutside);
1156 }
1157 if block.contains("Executor:") {
1158 bump(Risk::ExternalExecutor);
1159 }
1160 }
1161 }
1162 }
1163 Risk::ALL
1164 .iter()
1165 .zip(n)
1166 .filter(|(_, count)| *count > 0)
1167 .map(|(risk, count)| speccoder_core::RiskReason {
1168 reason: risk.as_str().to_owned(),
1169 count,
1170 text: risk.chip(count),
1171 })
1172 .collect()
1173}
1174
1175fn review_rank(i: &InboxItem) -> usize {
1179 use speccoder_core::inbox::Risk;
1180 match i.risk.first() {
1181 Some(r) => Risk::ALL
1182 .iter()
1183 .position(|x| x.as_str() == r.reason)
1184 .unwrap_or(0),
1185 None if EVIDENCED.contains(&i.kind.as_str()) => Risk::ALL.len() + 1,
1186 None => Risk::ALL.len(),
1187 }
1188}
1189
1190fn unverified(r: &StatusReport, path: &Path) -> Option<speccoder_core::Unverified> {
1194 use speccoder_core::ReasonCount;
1195 let ticks = r.totals.verified + r.totals.regression_only;
1196 let regression = r.totals.regression_only;
1197 if ticks < 3 || regression * 2 <= ticks || handed_off(r, path) {
1198 return None;
1199 }
1200 let mut counts: Vec<ReasonCount> = Vec::new();
1201 for t in r.tasks.iter().filter(|t| t.state == "regression_only") {
1202 let reason = t
1203 .regression
1204 .as_ref()
1205 .map(|g| g.reason.clone())
1206 .unwrap_or(speccoder_core::regression::RegressionReason::Unrecorded);
1207 let name = reason.name().to_owned();
1208 let by = match &reason {
1209 speccoder_core::regression::RegressionReason::AlreadyPassing {
1210 implemented_by: Some(speccoder_core::regression::ImplementedBy::Task { task, .. }),
1211 } => Some(task.clone()),
1212 _ => None,
1213 };
1214 let c = match counts.iter_mut().position(|c| c.reason == name) {
1215 Some(i) => &mut counts[i],
1216 None => {
1217 counts.push(ReasonCount {
1218 reason: name,
1219 n: 0,
1220 tasks: Vec::new(),
1221 implemented_by: Vec::new(),
1222 text: String::new(),
1223 });
1224 let last = counts.len() - 1;
1225 &mut counts[last]
1226 }
1227 };
1228 c.n += 1;
1229 c.tasks.push(t.id.clone());
1230 if let Some(b) = by.filter(|b| !c.implemented_by.contains(b)) {
1231 c.implemented_by.push(b);
1232 }
1233 }
1234 counts.sort_by(|a, b| b.n.cmp(&a.n).then(a.reason.cmp(&b.reason)));
1235 for c in &mut counts {
1236 c.text = reason_count_words(c);
1237 }
1238 Some(speccoder_core::Unverified {
1239 ticks,
1240 regression_only: regression,
1241 counts,
1242 })
1243}
1244
1245fn reason_count_words(c: &speccoder_core::ReasonCount) -> String {
1247 let n = c.n;
1248 let were = if n == 1 { "was" } else { "were" };
1249 let what = match c.reason.as_str() {
1250 "no_story" => "had no user story".to_owned(),
1251 "no_scenarios" => "had a story with no acceptance scenario".to_owned(),
1252 "acceptance_off" => format!("{were} ticked with acceptance tests off"),
1253 "author_wrote_none" => "got no test from the test author".to_owned(),
1254 "author_failed" => "had acceptance tests that could not be used".to_owned(),
1255 "already_passing" if c.implemented_by.is_empty() => format!("{were} already passing"),
1256 "already_passing" => format!(
1257 "{were} already passing — implemented by {}",
1258 c.implemented_by.join(", ")
1259 ),
1260 "not_red" => "had acceptance tests that did not fail first".to_owned(),
1261 "tests_not_collected" => "had acceptance tests that never ran".to_owned(),
1262 "planning_work" => format!("{were} planning work (design documents)"),
1263 "story_verified" => {
1264 format!("{were} their story's checkpoint, its scenarios verified earlier")
1265 }
1266 "deferred_to_checkpoint" => {
1267 format!("{were} judged at their story's checkpoint, as you decided")
1268 }
1269 _ => format!("{were} ticked before reasons were recorded"),
1270 };
1271 format!("{n} {what}")
1272}
1273
1274fn handed_off(r: &StatusReport, path: &Path) -> bool {
1277 let merged = !r.tasks.is_empty()
1278 && r.totals.ready == 0
1279 && r.totals.blocked == 0
1280 && r.unmerged_commits == 0
1281 && r.branch.is_some();
1282 let pushed = r.branch.as_deref().is_some_and(|b| {
1283 crate::git::Git::at(path)
1284 .line(&[
1285 "for-each-ref",
1286 "--format=%(refname)",
1287 &format!("refs/remotes/*/{b}"),
1288 ])
1289 .is_ok_and(|l| !l.trim().is_empty())
1290 });
1291 merged || pushed
1292}
1293
1294fn handoff(r: &StatusReport) -> bool {
1296 !r.tasks.is_empty()
1297 && r.totals.ready == 0
1298 && r.totals.blocked == 0
1299 && r.unmerged_commits > 0
1300 && r.branch.is_some()
1301}
1302
1303fn with_input(
1304 t: &speccoder_core::TaskStatus,
1305 verb: &str,
1306 why: &str,
1307 prompt: &str,
1308 run: Runnable,
1309 flag: &dyn Fn(String) -> String,
1310) -> Action {
1311 let option = if verb == "nudge" {
1312 "--note"
1313 } else {
1314 "--reason"
1315 };
1316 let mut a = action(
1317 "task",
1318 format!("{verb} {}", t.id),
1319 why,
1320 flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
1321 );
1322 a = a.with_run(run);
1323 a.input = Some(prompt.to_owned());
1324 a
1325}
1326
1327fn bug_item(
1329 b: &speccoder_core::bug::BugReport,
1330 flag: &dyn Fn(String) -> String,
1331) -> Option<InboxItem> {
1332 use speccoder_core::bug::Outcome;
1333 let key = b.key();
1334 let kind = match b.outcome {
1335 Outcome::AwaitingConfirmation => Kind::BugConfirm,
1336 Outcome::CannotReproduce => Kind::BugCannotReproduce,
1337 Outcome::UnchangedNotHolding => Kind::BugUnchangedNotHolding,
1338 Outcome::AlreadySatisfied if b.commit.is_some() => return None,
1340 Outcome::AlreadySatisfied => Kind::BugAlreadySatisfied,
1341 _ if b.code.as_deref() == Some("BUG-REPRO-WEAK") => Kind::BugReproWeak,
1342 _ => return None,
1343 };
1344 let task = || b.task.clone();
1345 let mut actions = Vec::new();
1346 match kind {
1347 Kind::BugConfirm => {
1348 let digest = b.confirm_digest.clone().unwrap_or_default();
1349 let mut yes = action(
1350 "next",
1351 format!("confirm {}'s reproductions", b.task),
1352 "they show the bug: they are frozen and the fix runs",
1353 format!("speccoder bug confirm {key} --digest {digest}"),
1354 );
1355 yes = yes.with_run(Runnable::BugConfirm {
1356 task: key.clone(),
1357 digest: digest.clone(),
1358 reject: false,
1359 });
1360 let mut no = action(
1361 "next",
1362 format!("reject {}'s reproductions", b.task),
1363 "the author tries again, told why",
1364 format!("speccoder bug confirm {key} --reject --reason \"…\""),
1365 );
1366 no = no.with_run(Runnable::BugConfirm {
1367 task: key.clone(),
1368 digest,
1369 reject: true,
1370 });
1371 no.input = Some("Why they do not show the bug".to_owned());
1372 actions.extend([yes, no]);
1373 }
1374 Kind::BugAlreadySatisfied => {
1375 let mut keep = action(
1376 "next",
1377 format!("keep {}'s reproduction as a regression test", b.task),
1378 "the reproduction is committed and the task accepted on your word: attested, not verified",
1379 format!("speccoder bug keep {key} --reason \"kept as a regression test\""),
1380 );
1381 keep = keep.with_run(Runnable::BugKeep { task: key.clone() });
1382 keep.input = Some("Why it is kept".to_owned());
1383 let mut drop = action(
1384 "task",
1385 format!("drop {}", b.task),
1386 "it is fixed already",
1387 flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1388 );
1389 drop = drop.with_run(Runnable::Skip { task: task() });
1390 drop.input = Some("Why it is dropped".to_owned());
1391 actions.extend([keep, drop]);
1392 }
1393 Kind::BugReproWeak => {
1394 let mut rule = action(
1395 "task",
1396 format!("rule on {}", b.task),
1397 "the tests are right: the special case is not a fix",
1398 flag(format!(
1399 "speccoder task rule {} --tests-right --reason \"…\"",
1400 b.task
1401 )),
1402 );
1403 rule = rule.with_run(Runnable::Rule { task: task() });
1404 rule.input = Some("Why the tests are right".to_owned());
1405 actions.push(rule);
1406 actions.push(action(
1407 "task",
1408 "bring your own test",
1409 "one per Expected statement",
1410 format!(
1411 "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1412 b.triad.current.text
1413 ),
1414 ));
1415 }
1416 _ => {
1417 let mut again = action(
1418 "next",
1419 format!("reproduce {} again", b.task),
1420 "a fresh set of tries",
1421 format!("speccoder bug run --task {key}"),
1422 );
1423 again = again.with_run(Runnable::BugRun {
1424 slug: None,
1425 task: Some(key.clone()),
1426 });
1427 actions.push(again);
1428 actions.push(action(
1429 "task",
1430 "bring your own test",
1431 "one per Expected statement",
1432 format!(
1433 "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1434 b.triad.current.text
1435 ),
1436 ));
1437 if !b.home.kind.native() {
1438 actions.push(action(
1439 "task",
1440 format!("reword {}", b.requirement),
1441 "the triad in other words (what was confirmed lapses)",
1442 format!(
1443 "speccoder review edit {} \"…\" --feature {}",
1444 b.requirement, b.unit
1445 ),
1446 ));
1447 let mut drop = action(
1448 "task",
1449 format!("drop {}", b.task),
1450 "nothing is fixed",
1451 flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1452 );
1453 drop = drop.with_run(Runnable::Skip { task: task() });
1454 drop.input = Some("Why it is dropped".to_owned());
1455 actions.push(drop);
1456 }
1457 }
1458 }
1459 let detail = b
1460 .handoff
1461 .as_ref()
1462 .map(|h| {
1463 format!(
1464 "{} tries; reproduced: {}; not reproduced: {}; already passing: {}",
1465 h.tries.len(),
1466 if h.statements.reproduced.is_empty() {
1467 "none".to_owned()
1468 } else {
1469 h.statements.reproduced.join(", ")
1470 },
1471 if h.statements.not_reproduced.is_empty() {
1472 "none".to_owned()
1473 } else {
1474 h.statements.not_reproduced.join(", ")
1475 },
1476 if h.statements.satisfied.is_empty() {
1477 "none".to_owned()
1478 } else {
1479 h.statements.satisfied.join(", ")
1480 },
1481 )
1482 })
1483 .unwrap_or_else(|| format!("{} — {}", b.title, b.outcome.as_str().replace('_', " ")));
1484 Some(InboxItem {
1485 unverified: None,
1486 risk: Vec::new(),
1487 tool: None,
1488 kind: kind.as_str().to_owned(),
1489 group: kind.group().as_str().to_owned(),
1490 feature: b.unit.clone(),
1491 task: Some(b.task.clone()),
1492 title: match kind {
1493 Kind::BugConfirm => format!(
1494 "{}: {} reproduction(s) wait for your confirmation",
1495 b.task,
1496 b.reproductions.iter().filter(|r| r.reproduces()).count()
1497 ),
1498 Kind::BugAlreadySatisfied => format!(
1499 "{}: already fixed — keep the reproduction or drop it",
1500 b.task
1501 ),
1502 Kind::BugReproWeak => {
1503 format!("{}: a special-case fix passes the reproductions", b.task)
1504 }
1505 Kind::BugUnchangedNotHolding => {
1506 format!("{}: an Unchanged statement does not hold today", b.task)
1507 }
1508 _ => format!("{}: could not be reproduced", b.task),
1509 },
1510 detail,
1511 since: b.confirmation.as_ref().map(|c| c.at),
1512 actions,
1513 })
1514}
1515
1516fn retry_blocked(r: &StatusReport, waiting: &[&str]) -> Option<String> {
1519 if !r.uncommitted.is_empty() {
1520 Some("commit the planning files first".to_owned())
1521 } else {
1522 waiting
1523 .first()
1524 .map(|w| format!("{w} waits for your decision and holds the worktree: decide it first"))
1525 }
1526}
1527
1528fn dependents(tasks: &[speccoder_core::TaskStatus], id: &str) -> Vec<String> {
1530 let mut waiting: Vec<&str> = vec![id];
1531 loop {
1532 let more: Vec<&str> = tasks
1533 .iter()
1534 .filter(|t| !waiting.contains(&t.id.as_str()))
1535 .filter(|t| t.blocked_by.iter().any(|b| waiting.contains(&b.as_str())))
1536 .map(|t| t.id.as_str())
1537 .collect();
1538 if more.is_empty() {
1539 break;
1540 }
1541 waiting.extend(more);
1542 }
1543 tasks
1544 .iter()
1545 .filter(|t| t.id != id && waiting.contains(&t.id.as_str()))
1546 .map(|t| t.id.clone())
1547 .collect()
1548}
1549
1550fn yours_actions(
1553 t: &speccoder_core::TaskStatus,
1554 waiting: &[String],
1555 retry: Option<String>,
1556 flag: &dyn Fn(String) -> String,
1557) -> Vec<Action> {
1558 let mut done = action(
1559 "task",
1560 format!("I did it: {}", t.id),
1561 "tick it on your word (attested, never shown as verified)",
1562 flag(format!("speccoder task done {}", t.id)),
1563 );
1564 done = done.with_run(Runnable::Done { task: t.id.clone() });
1565 let mut hint = action(
1566 "task",
1567 format!("hint & retry {}", t.id),
1568 "a note the agent reads, then a new attempt now",
1569 flag(format!("speccoder task hint {} \"…\"", t.id)),
1570 );
1571 hint = hint.with_run(Runnable::Hint { task: t.id.clone() });
1572 hint.input = Some("The hint".to_owned());
1573 hint.blocked = retry;
1574 let mut skip = skip(t, flag);
1575 if !waiting.is_empty() {
1576 let stay = if waiting.len() == 1 { "stays" } else { "stay" };
1577 skip.why = format!("runs pass it by; {} {stay} blocked", waiting.join(", "));
1578 }
1579 vec![done, hint, skip]
1580}
1581
1582fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1583 with_input(
1584 t,
1585 "nudge",
1586 "a note its next attempts read",
1587 "The note",
1588 Runnable::Nudge { task: t.id.clone() },
1589 flag,
1590 )
1591}
1592
1593fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1594 with_input(
1595 t,
1596 "skip",
1597 "runs pass it by; what depends on it stays blocked",
1598 "Why it is skipped",
1599 Runnable::Skip { task: t.id.clone() },
1600 flag,
1601 )
1602}
1603
1604fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1605 with_input(
1606 t,
1607 "accept",
1608 "tick it on your word (attested, never shown as verified)",
1609 "Why it is done",
1610 Runnable::Accept { task: t.id.clone() },
1611 flag,
1612 )
1613}
1614
1615fn try_with(
1619 task: &str,
1620 last: &speccoder_core::LastAttempt,
1621 flag: &dyn Fn(String) -> String,
1622 on_path: impl Fn(&str) -> bool,
1623) -> Option<Action> {
1624 let current = last
1625 .run_id
1626 .as_deref()
1627 .and_then(|r| r.strip_prefix("external_"))
1628 .and_then(|r| r.split('_').next())
1629 .filter(|a| *a != "command")?;
1630 let next = crate::acp::ready::next_after(Some(current), &on_path)?;
1631 Some(
1632 action(
1633 "work",
1634 format!("try {task} with {}", next.name),
1635 "the same task, another agent: one attempt, judged the same way",
1636 flag(format!("speccoder run --task {task} --agent {}", next.id)),
1637 )
1638 .with_run(Runnable::Run {
1639 task: Some(task.to_owned()),
1640 agent: Some(next.id.to_owned()),
1641 }),
1642 )
1643}
1644
1645#[cfg(test)]
1646mod try_with_tests {
1647 use super::*;
1648
1649 #[test]
1650 fn a_failed_agent_attempt_offers_the_next_agent_and_a_native_one_does_not() {
1651 let t = "T005";
1652 let last = |run: &str| speccoder_core::LastAttempt {
1653 outcome: "verification_failed".to_owned(),
1654 reason: String::new(),
1655 run_id: Some(run.to_owned()),
1656 reference: String::new(),
1657 defer_to: None,
1658 };
1659 let flag = |c: String| c;
1660 let both = |x: &str| x == "copilot" || x == "opencode";
1661 let a = try_with(t, &last("external_copilot_1"), &flag, both)
1662 .unwrap_or_else(|| panic!("offered"));
1663 assert_eq!(a.command, "speccoder run --task T005 --agent opencode");
1664 assert_eq!(
1665 a.run,
1666 Some(Runnable::Run {
1667 task: Some("T005".to_owned()),
1668 agent: Some("opencode".to_owned())
1669 })
1670 );
1671 assert!(try_with(t, &last("run_01ABC"), &flag, both).is_none());
1672 assert!(try_with(t, &last("external_command_1"), &flag, both).is_none());
1673 let alone = |x: &str| x == "copilot";
1674 assert!(try_with(t, &last("external_copilot_1"), &flag, alone).is_none());
1675 }
1676}
1677
1678#[cfg(test)]
1679mod order_tests {
1680 use super::*;
1681
1682 fn at(kind: &str, feature: &str, since: Option<u64>, risk: &[(&str, usize)]) -> InboxItem {
1683 InboxItem {
1684 kind: kind.to_owned(),
1685 group: Kind::named(kind)
1686 .map_or("blocked_on_you", |k| k.group().as_str())
1687 .to_owned(),
1688 feature: feature.to_owned(),
1689 task: None,
1690 title: String::new(),
1691 detail: String::new(),
1692 since,
1693 actions: Vec::new(),
1694 unverified: None,
1695 tool: None,
1696 risk: risk
1697 .iter()
1698 .map(|(r, n)| speccoder_core::RiskReason {
1699 reason: (*r).to_owned(),
1700 count: *n,
1701 text: String::new(),
1702 })
1703 .collect(),
1704 }
1705 }
1706
1707 #[test]
1708 fn blocked_comes_first_oldest_first_and_review_riskiest_first() {
1709 let mut items = vec![
1710 at("handoff", "strong", None, &[]),
1711 at("review", "spec", None, &[]),
1712 at("handoff", "attested", None, &[("attested", 1)]),
1713 at("question", "q", Some(20), &[]),
1714 at(
1715 "unverified",
1716 "regression",
1717 None,
1718 &[("regression_only", 3), ("attested", 1)],
1719 ),
1720 at("approval", "a", Some(30), &[]),
1721 at("stale", "stale", None, &[("stale", 2)]),
1722 at("unknown", "unreadable", None, &[]),
1723 at("stuck", "s", Some(10), &[]),
1724 ];
1725 sort(&mut items);
1726 let order: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
1727 assert_eq!(
1728 order,
1729 [
1730 "unreadable",
1732 "s",
1733 "q",
1734 "a",
1735 "regression",
1737 "attested",
1738 "stale",
1739 "spec",
1740 "strong",
1741 ]
1742 );
1743 }
1744}