Skip to main content

speccoder_runtime/
inbox.rs

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