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