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