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